//! Integration tests for the IMAP client.
//!
//! These tests run against real IMAP servers in Docker.
//!
//! # Setup
//!
//! ```sh
//! docker compose up -d           # start Dovecot + GreenMail
//! cargo test -p daaki-imap -- --ignored
//! docker compose down
//! ```
//!
//! # Servers under test
//!
//! - Dovecot (C, industry standard) — ports 14143/14993
//! - GreenMail (Java, test-oriented) — ports 13143/13993
//!
//! Each logical test is run against every server to catch implementation-specific
//! quirks and spec deviations.

#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::doc_markdown,
    clippy::needless_continue,
    clippy::significant_drop_tightening
)]

use std::sync::{Arc, Once, OnceLock};
use std::time::Duration;

use daaki_imap::types::{
    Capability, FetchAttr, Flag, MailboxAttribute, SequenceSet, StoreOperation,
};
use daaki_imap::{ImapConnection, MailboxName, SessionState, StatusItem, TlsMode};

static TRACING_INIT: Once = Once::new();

fn init_tracing() {
    TRACING_INIT.call_once(|| {
        tracing_subscriber::fmt()
            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
            .with_test_writer()
            .try_init()
            .ok();
    });
}

const TIMEOUT: Duration = Duration::from_secs(10);

/// Minimal RFC 5322 message for APPEND tests.
const TEST_MESSAGE: &[u8] = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Integration test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <test-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Hello from daaki integration tests.\r\n";

// ---------------------------------------------------------------------------
// Server configurations
// ---------------------------------------------------------------------------

mod dovecot {
    pub const HOST: &str = "127.0.0.1";
    pub const IMAP_PORT: u16 = 14143;
    pub const IMAPS_PORT: u16 = 14993;
    pub const USER: &str = "testuser";
    pub const PASS: &str = "testpass";
}

mod greenmail {
    pub const HOST: &str = "127.0.0.1";
    pub const IMAP_PORT: u16 = 13143;
    pub const IMAPS_PORT: u16 = 13993;
    pub const USER: &str = "testuser";
    pub const PASS: &str = "testpass";
}

/// Limit helper-based Dovecot sessions so ignored tests stay below the
/// server's default `mail_max_userip_connections=10` cap.
fn dovecot_connection_limiter() -> &'static Arc<tokio::sync::Semaphore> {
    static LIMITER: OnceLock<Arc<tokio::sync::Semaphore>> = OnceLock::new();
    LIMITER.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(6)))
}

struct DovecotConnection {
    conn: ImapConnection,
    _permit: tokio::sync::OwnedSemaphorePermit,
}

impl std::ops::Deref for DovecotConnection {
    type Target = ImapConnection;

    fn deref(&self) -> &Self::Target {
        &self.conn
    }
}

// ==========================================================================
// Connection & Authentication
// ==========================================================================

// --- Dovecot ---

/// Connect to Dovecot over plaintext, STARTTLS, then LOGIN.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_plain_and_login() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// Connect to Dovecot over implicit TLS (port 14993, self-signed cert).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_implicit_tls() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAPS_PORT,
        TlsMode::Implicit,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// Connect to Dovecot, upgrade via STARTTLS (self-signed cert), then LOGIN.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_starttls() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

// --- GreenMail ---

/// Connect to GreenMail over plaintext and LOGIN.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_connect_plain_and_login() {
    let conn = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAP_PORT,
        TlsMode::None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.login(greenmail::USER, greenmail::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// Connect to GreenMail over implicit TLS (port 13993, self-signed cert).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_connect_implicit_tls() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        greenmail::HOST,
        greenmail::IMAPS_PORT,
        TlsMode::Implicit,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.login(greenmail::USER, greenmail::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// Implicit TLS with the default WebPKI roots must reject Dovecot's
/// untrusted Docker certificate.
///
/// RFC 3501 Section 6.1.1 requires the protected connection to be
/// established before the session proceeds. The default `connect`
/// helper uses WebPKI roots, so an untrusted/self-signed test
/// certificate must be rejected unless the caller opts into
/// `connect_with_tls_config`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_implicit_tls_with_default_roots_rejects_untrusted_certificate() {
    init_tracing();

    let result = ImapConnection::connect(
        dovecot::HOST,
        dovecot::IMAPS_PORT,
        TlsMode::Implicit,
        TIMEOUT,
    )
    .await;

    assert_tls_validation_error(result);
}

/// Implicit TLS with the default WebPKI roots must reject GreenMail's
/// untrusted Docker certificate.
///
/// RFC 3501 Section 6.1.1 requires the protected connection to be
/// established before the session proceeds. The default `connect`
/// helper uses WebPKI roots, so an untrusted/self-signed test
/// certificate must be rejected unless the caller opts into
/// `connect_with_tls_config`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_connect_implicit_tls_with_default_roots_rejects_untrusted_certificate() {
    init_tracing();

    let result = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAPS_PORT,
        TlsMode::Implicit,
        TIMEOUT,
    )
    .await;

    assert_tls_validation_error(result);
}

/// `TlsMode::StartTls` must complete the STARTTLS upgrade before returning.
///
/// RFC 3501 Section 6.2.1 requires the client to negotiate TLS before using
/// the protected session. The public `TlsMode::StartTls` contract likewise
/// means "connect in cleartext, then upgrade via STARTTLS".
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_starttls_mode_auto_upgrades_before_return() {
    init_tracing();
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::StartTls,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "connect should return a pre-authenticated session after STARTTLS"
    );

    let caps = conn.capabilities();
    assert!(
        !caps.iter().any(|cap| matches!(cap, Capability::StartTls)),
        "post-STARTTLS capabilities must be refreshed and must not advertise STARTTLS: {caps:?}"
    );

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// A connection created with `TlsMode::StartTls` must not require a second
/// manual STARTTLS call.
///
/// RFC 3501 Section 6.2.1 allows STARTTLS only on the cleartext session. Once
/// `connect_with_tls_config(..., TlsMode::StartTls, ...)` returns, the
/// protected channel is already established.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_starttls_mode_rejects_second_manual_upgrade() {
    init_tracing();
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::StartTls,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    let result = conn.starttls_with_config(tls_config, TIMEOUT).await;
    assert!(
        matches!(
            result,
            Err(daaki_imap::Error::StartTlsUnavailable | daaki_imap::Error::Protocol(_))
        ),
        "a connection already upgraded via TlsMode::StartTls must reject a second STARTTLS, got {result:?}"
    );

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// Requesting `TlsMode::StartTls` against a server that does not advertise
/// `STARTTLS` must fail during connect.
///
/// RFC 3501 Section 6.2.1 defines STARTTLS as an advertised capability. A
/// client that explicitly requests `TlsMode::StartTls` must not silently return
/// a plaintext connection instead.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_connect_starttls_mode_requires_server_starttls_capability() {
    init_tracing();
    let result = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAP_PORT,
        TlsMode::StartTls,
        TIMEOUT,
    )
    .await;

    assert!(
        matches!(result, Err(daaki_imap::Error::StartTlsUnavailable)),
        "expected Error::StartTlsUnavailable when STARTTLS is requested from a server that does not advertise it, got {result:?}"
    );
}

/// `TlsMode::StartTls` with the default WebPKI roots must fail the TLS
/// negotiation against Dovecot's untrusted Docker certificate.
///
/// RFC 3501 Section 6.2.1 requires the client to complete TLS
/// negotiation before continuing the IMAP session. The default
/// `connect` helper therefore must not silently accept an untrusted
/// certificate on the STARTTLS path.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_connect_starttls_mode_with_default_roots_rejects_untrusted_certificate() {
    init_tracing();

    let result = ImapConnection::connect(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::StartTls,
        TIMEOUT,
    )
    .await;

    assert_tls_validation_error(result);
}

/// A failed STARTTLS handshake must leave the connection unusable
/// rather than falling back to cleartext.
///
/// RFC 3501 Section 6.2.1 requires the protected channel to be
/// negotiated before further IMAP traffic. The public `starttls()`
/// contract likewise requires an atomic upgrade: if certificate
/// validation fails, the connection transitions to `Logout` and
/// rejects subsequent commands.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_starttls_with_default_roots_rejects_untrusted_certificate_and_closes_connection() {
    init_tracing();
    let conn = ImapConnection::connect(dovecot::HOST, dovecot::IMAP_PORT, TlsMode::None, TIMEOUT)
        .await
        .unwrap();

    let result = conn.starttls(TIMEOUT).await;
    assert_tls_validation_error(result);
    assert_eq!(
        conn.session_state(),
        SessionState::Logout,
        "failed STARTTLS must leave the connection in Logout state"
    );

    let select_result = conn.select("INBOX", TIMEOUT).await;
    assert!(
        select_result.is_err(),
        "connection must reject further commands after failed STARTTLS, got {select_result:?}"
    );
}

/// AUTHENTICATE PLAIN requires the server to advertise `AUTH=PLAIN`.
///
/// RFC 3501 Section 6.2.2 requires the client to select a server-advertised
/// SASL mechanism. GreenMail's IMAP server in this Docker environment does not
/// advertise `AUTH=PLAIN`, so the client must reject the request locally.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_authenticate_plain_requires_advertised_capability() {
    init_tracing();
    let conn = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAP_PORT,
        TlsMode::None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let caps = conn.capabilities();
    assert!(
        !caps
            .iter()
            .any(|cap| matches!(cap, Capability::Auth(mech) if mech.eq_ignore_ascii_case("PLAIN"))),
        "test requires GreenMail to omit AUTH=PLAIN from capabilities: {caps:?}"
    );

    let result = conn
        .authenticate_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::MissingCapability(ref cap)) if cap == "AUTH=PLAIN"),
        "expected Error::MissingCapability(\"AUTH=PLAIN\"), got {result:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "failed AUTHENTICATE must leave the session pre-authenticated"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Mailbox operations (RFC 3501 Section 6.3)
// ==========================================================================

/// LIST mailboxes — every server should have at least INBOX.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_mailboxes() {
    let conn = connect_dovecot().await;

    let mailboxes = conn.list("", "*", TIMEOUT).await.unwrap();
    assert!(
        mailboxes
            .iter()
            .any(|m| m.name.as_str().eq_ignore_ascii_case("INBOX")),
        "INBOX not found in LIST response: {mailboxes:?}",
    );

    conn.logout().await.unwrap();
}

#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_list_mailboxes() {
    let conn = connect_greenmail().await;

    let mailboxes = conn.list("", "*", TIMEOUT).await.unwrap();
    assert!(
        mailboxes
            .iter()
            .any(|m| m.name.as_str().eq_ignore_ascii_case("INBOX")),
        "INBOX not found in LIST response: {mailboxes:?}",
    );

    conn.logout().await.unwrap();
}

/// LIST with a non-empty reference name must resolve a relative mailbox name
/// against that reference and return the canonical mailbox name per RFC 3501
/// Section 6.3.8.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_list_reference_resolves_relative_mailbox_name() {
    let conn = connect_greenmail().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_mailbox_name("gm-list-reference-parent");
    let child = format!("{parent}{delimiter}child");
    let reference = format!("{parent}{delimiter}");

    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();

    let listing = conn.list(&reference, "child", TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "LIST with a relative mailbox name should resolve to one canonical match: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), child.as_str());

    conn.delete(&child, TIMEOUT).await.unwrap();
    conn.delete(&parent, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// LIST with `%` under a non-empty reference name must match only the direct
/// children of that reference, not deeper descendants, per RFC 3501 Section
/// 6.3.8.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_list_reference_percent_matches_only_direct_children() {
    let conn = connect_greenmail().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_mailbox_name("gm-list-percent-parent");
    let child = format!("{parent}{delimiter}child");
    let grandchild = format!("{child}{delimiter}grandchild");
    let reference = format!("{parent}{delimiter}");

    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();
    conn.create(&grandchild, TIMEOUT).await.unwrap();

    let listing = conn.list(&reference, "%", TIMEOUT).await.unwrap();
    let mut names: Vec<_> = listing
        .iter()
        .map(|info| info.name.as_str().to_owned())
        .collect();
    names.sort();
    assert_eq!(
        names,
        vec![child.clone()],
        "LIST % under a reference must return only direct children: {listing:?}"
    );

    conn.delete(&grandchild, TIMEOUT).await.unwrap();
    conn.delete(&child, TIMEOUT).await.unwrap();
    conn.delete(&parent, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SELECT INBOX — should return valid mailbox metadata.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_inbox() {
    let conn = connect_dovecot().await;

    let selected = conn.select("INBOX", TIMEOUT).await.unwrap();
    // UIDVALIDITY must be non-zero per RFC 3501 Section 2.3.1.1.
    assert!(
        selected.uid_validity.unwrap_or(0) > 0,
        "UIDVALIDITY must be > 0"
    );

    conn.logout().await.unwrap();
}

#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_select_inbox() {
    let conn = connect_greenmail().await;

    let selected = conn.select("INBOX", TIMEOUT).await.unwrap();
    assert!(
        selected.uid_validity.unwrap_or(0) > 0,
        "UIDVALIDITY must be > 0"
    );

    conn.logout().await.unwrap();
}

/// CREATE then SELECT a new mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_mailbox() {
    let conn = connect_dovecot().await;

    let name = &format!("test-create-{}", std::process::id());
    conn.create(name, TIMEOUT).await.unwrap();

    let selected = conn.select(name, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0);

    conn.logout().await.unwrap();
}

/// CREATE then SELECT a new mailbox on GreenMail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_create_mailbox() {
    let conn = connect_greenmail().await;

    let name = &format!("test-gm-create-{}", std::process::id());
    conn.create(name, TIMEOUT).await.unwrap();

    let selected = conn.select(name, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0);

    conn.logout().await.unwrap();
}

/// DELETE a mailbox on GreenMail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_delete_mailbox() {
    let conn = connect_greenmail().await;

    let name = &format!("test-gm-delete-{}", std::process::id());
    conn.create(name, TIMEOUT).await.unwrap();

    // Verify it exists via LIST
    let mailboxes = conn.list("", name, TIMEOUT).await.unwrap();
    assert!(!mailboxes.is_empty(), "mailbox should exist after CREATE");

    conn.delete(name, TIMEOUT).await.unwrap();

    // Verify it's gone
    let mailboxes = conn.list("", name, TIMEOUT).await.unwrap();
    assert!(mailboxes.is_empty(), "mailbox should be gone after DELETE");

    conn.logout().await.unwrap();
}

/// RENAME a mailbox on GreenMail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_rename_mailbox() {
    let conn = connect_greenmail().await;

    let old_name = &format!("test-gm-rename-old-{}", std::process::id());
    let new_name = &format!("test-gm-rename-new-{}", std::process::id());
    conn.create(old_name, TIMEOUT).await.unwrap();

    conn.rename(old_name, new_name, TIMEOUT).await.unwrap();

    // Old name should be gone, new name should exist
    let old_list = conn.list("", old_name, TIMEOUT).await.unwrap();
    assert!(old_list.is_empty(), "old name should be gone");

    let new_list = conn.list("", new_name, TIMEOUT).await.unwrap();
    assert!(!new_list.is_empty(), "new name should exist");

    conn.logout().await.unwrap();
}

/// RENAME must preserve the renamed mailbox's messages on GreenMail per RFC
/// 3501 Section 6.3.5.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_rename_preserves_messages() {
    let conn = connect_greenmail().await;

    let old_name = unique_mailbox_name("gm-rename-msg-old");
    let new_name = unique_mailbox_name("gm-rename-msg-new");
    conn.create(&old_name, TIMEOUT).await.unwrap();
    conn.select(&old_name, TIMEOUT).await.unwrap();

    conn.append(&old_name, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&old_name, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();

    conn.rename(&old_name, &new_name, TIMEOUT).await.unwrap();

    let selected = conn.select(&new_name, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 2,
        "renamed mailbox should preserve both messages"
    );

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "expected both messages after RENAME");
    let seen_count = fetches
        .iter()
        .filter(|fetch| {
            fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.contains(&Flag::Seen))
        })
        .count();
    assert_eq!(
        seen_count, 1,
        "RENAME must preserve the original message flags"
    );

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &new_name).await;
    conn.logout().await.unwrap();
}

// ==========================================================================
// APPEND + FETCH (RFC 3501 Section 6.3.11, 6.4.5)
// ==========================================================================

/// Append a message, then fetch it back and verify envelope fields.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_and_fetch() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    // APPEND with \Seen flag.
    conn.append("INBOX", &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // FETCH the last message by UID.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Flags,
                FetchAttr::Envelope,
                FetchAttr::Rfc822Size,
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH response");

    let msg = &fetches[0];
    assert!(msg.uid.is_some());
    assert!(msg.rfc822_size.is_some());

    // Verify flags include \Seen.
    if let Some(flags) = &msg.flags {
        assert!(flags.contains(&Flag::Seen), "expected \\Seen flag");
    }

    // Verify envelope subject.
    if let Some(env) = &msg.envelope {
        assert_eq!(env.subject.as_deref(), Some("Integration test"));
    }

    conn.logout().await.unwrap();
}

#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_append_and_fetch() {
    let conn = connect_greenmail().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.append("INBOX", &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Flags,
                FetchAttr::Envelope,
                FetchAttr::Rfc822Size,
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH response");

    let msg = &fetches[0];
    assert!(msg.uid.is_some());

    if let Some(env) = &msg.envelope {
        assert_eq!(env.subject.as_deref(), Some("Integration test"));
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// STORE flags (RFC 3501 Section 6.4.6)
// ==========================================================================

/// Append a message, then add \Flagged via STORE, then verify.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_flags() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.append("INBOX", &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Fetch UID of the appended message.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    // Add \Flagged.
    let uid_set = uid.to_string();
    conn.uid_store(
        &SequenceSet::new(&uid_set).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Re-fetch and verify.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(&uid_set).unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Flagged),
        "expected \\Flagged after STORE"
    );

    conn.logout().await.unwrap();
}

/// Append a message, then add \Flagged via STORE on GreenMail, then verify.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_store_flags() {
    let conn = connect_greenmail().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.append("INBOX", &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Fetch UID of the appended message.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    // Add \Flagged.
    let uid_set = uid.to_string();
    conn.uid_store(
        &SequenceSet::new(&uid_set).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Re-fetch and verify.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(&uid_set).unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Flagged),
        "expected \\Flagged after STORE"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// COPY / MOVE / EXPUNGE (RFC 3501 Section 6.4.7, RFC 6851, RFC 4315)
// ==========================================================================

/// Append, COPY to another mailbox, verify message exists in target.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_message() {
    let conn = connect_dovecot().await;

    let target = &format!("test-copy-{}", std::process::id());
    conn.create(target, TIMEOUT).await.unwrap();
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.append("INBOX", &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    conn.uid_copy(&SequenceSet::new(uid.to_string()).unwrap(), target, TIMEOUT)
        .await
        .unwrap();

    // Verify message arrived in target.
    let selected = conn.select(target, TIMEOUT).await.unwrap();
    assert!(
        selected.exists >= 1,
        "expected at least 1 message in {target}"
    );

    conn.logout().await.unwrap();
}

/// Append, MOVE to another mailbox, verify source is empty.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_move_message() {
    let conn = connect_dovecot().await;

    let source = &format!("test-move-src-{}", std::process::id());
    let target = &format!("test-move-dst-{}", std::process::id());
    conn.create(source, TIMEOUT).await.unwrap();
    conn.create(target, TIMEOUT).await.unwrap();
    conn.select(source, TIMEOUT).await.unwrap();

    conn.append(source, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    conn.uid_move_messages(&SequenceSet::new(uid.to_string()).unwrap(), target, TIMEOUT)
        .await
        .unwrap();

    // Source should now be empty after MOVE + implicit expunge.
    let selected = conn.select(source, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 0,
        "source mailbox should be empty after MOVE"
    );

    // Target should have the message.
    let selected = conn.select(target, TIMEOUT).await.unwrap();
    assert!(
        selected.exists >= 1,
        "target mailbox should have the message"
    );

    conn.logout().await.unwrap();
}

/// EXPUNGE removes deleted messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_expunge() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-expunge-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    // Mark \Deleted, then EXPUNGE.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.expunge(TIMEOUT).await.unwrap();

    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0, "mailbox should be empty after EXPUNGE");

    conn.logout().await.unwrap();
}

/// Append, COPY to another mailbox on GreenMail, verify message exists in target.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_copy_message() {
    let conn = connect_greenmail().await;

    let target = &format!("test-gm-copy-{}", std::process::id());
    conn.create(target, TIMEOUT).await.unwrap();
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.append("INBOX", &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    conn.uid_copy(&SequenceSet::new(uid.to_string()).unwrap(), target, TIMEOUT)
        .await
        .unwrap();

    // Verify message arrived in target.
    let selected = conn.select(target, TIMEOUT).await.unwrap();
    assert!(
        selected.exists >= 1,
        "expected at least 1 message in {target}"
    );

    conn.logout().await.unwrap();
}

/// EXPUNGE removes deleted messages on GreenMail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_expunge() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-expunge-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    // Mark \Deleted, then EXPUNGE.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.expunge(TIMEOUT).await.unwrap();

    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0, "mailbox should be empty after EXPUNGE");

    conn.logout().await.unwrap();
}

// ==========================================================================
// IDLE (RFC 2177)
// ==========================================================================

/// Start IDLE, cancel it via token, verify we get Cancelled event.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_idle_cancel() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    let cancel = tokio_util::sync::CancellationToken::new();
    let cancel_clone = cancel.clone();

    // Cancel after a short delay.
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        cancel_clone.cancel();
    });

    let event = conn.idle(TIMEOUT, cancel).await.unwrap();
    assert_eq!(event, daaki_imap::IdleEvent::Cancelled);

    conn.logout().await.unwrap();
}

/// IDLE must remain cancellable on GreenMail per RFC 2177 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_idle_cancel() {
    let conn = connect_greenmail().await;
    let mailbox = unique_mailbox_name("gm-idle-cancel");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let cancel = tokio_util::sync::CancellationToken::new();
    let cancel_clone = cancel.clone();

    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        cancel_clone.cancel();
    });

    let deadline = std::time::Instant::now() + TIMEOUT;
    let mut saw_cancelled = false;
    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        match conn.idle(remaining, cancel.clone()).await.unwrap() {
            daaki_imap::IdleEvent::Cancelled => {
                saw_cancelled = true;
                break;
            }
            daaki_imap::IdleEvent::Timeout => continue,
            _ => {}
        }
    }
    assert!(
        saw_cancelled,
        "IDLE cancellation should eventually surface as Cancelled"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// IDLE on a selected mailbox must surface a new-message EXISTS update when a
/// second session APPENDs to that mailbox per RFC 2177 Section 3 and RFC 3501
/// Section 7.3.1.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_idle_exists_event() {
    let conn = connect_greenmail().await;

    let mailbox = unique_mailbox_name("gm-idle-exists");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let exists_observed = tokio_util::sync::CancellationToken::new();
    let mailbox_for_appender = mailbox.clone();
    let exists_observed_for_appender = exists_observed.clone();
    let appender_handle = tokio::spawn(async move {
        let appender = connect_greenmail().await;
        for _ in 0..5 {
            tokio::time::sleep(Duration::from_millis(200)).await;
            if exists_observed_for_appender.is_cancelled() {
                break;
            }
            appender
                .append(&mailbox_for_appender, &[], None, TEST_MESSAGE, TIMEOUT)
                .await
                .unwrap();
        }
        appender.logout().await.unwrap();
    });

    let deadline = std::time::Instant::now() + TIMEOUT;
    let mut exists = None;
    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        let cancel = tokio_util::sync::CancellationToken::new();
        match conn.idle(remaining, cancel).await.unwrap() {
            daaki_imap::IdleEvent::Exists(count) => {
                exists = Some(count);
                exists_observed.cancel();
                break;
            }
            daaki_imap::IdleEvent::Timeout => continue,
            _ => {}
        }
    }
    exists_observed.cancel();
    appender_handle.await.unwrap();

    assert!(
        matches!(exists, Some(count) if count >= 1),
        "IDLE should report a positive mailbox EXISTS count after APPEND: {exists:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

// ==========================================================================
// EXAMINE — read-only SELECT (RFC 3501 Section 6.3.2)
// ==========================================================================

#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_examine_inbox() {
    let conn = connect_dovecot().await;

    let selected = conn.examine("INBOX", TIMEOUT).await.unwrap();
    assert!(selected.uid_validity.unwrap_or(0) > 0);

    conn.logout().await.unwrap();
}

#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_examine_inbox() {
    let conn = connect_greenmail().await;

    let selected = conn.examine("INBOX", TIMEOUT).await.unwrap();
    assert!(selected.uid_validity.unwrap_or(0) > 0);

    conn.logout().await.unwrap();
}

/// EXAMINE opens the mailbox in read-only mode.
///
/// RFC 3501 Section 6.3.2 defines EXAMINE as the read-only variant of SELECT.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_examine_is_read_only() {
    let conn = connect_greenmail().await;

    let selected = conn.examine("INBOX", TIMEOUT).await.unwrap();
    assert!(
        selected.read_only,
        "EXAMINE should report the mailbox as read-only"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// DELETE / RENAME / CLOSE / STATUS / UID SEARCH
// ==========================================================================

/// DELETE a mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_mailbox() {
    let conn = connect_dovecot().await;

    let name = &format!("test-delete-{}", std::process::id());
    conn.create(name, TIMEOUT).await.unwrap();

    // Verify it exists via LIST
    let mailboxes = conn.list("", name, TIMEOUT).await.unwrap();
    assert!(!mailboxes.is_empty(), "mailbox should exist after CREATE");

    conn.delete(name, TIMEOUT).await.unwrap();

    // Verify it's gone
    let mailboxes = conn.list("", name, TIMEOUT).await.unwrap();
    assert!(mailboxes.is_empty(), "mailbox should be gone after DELETE");

    conn.logout().await.unwrap();
}

/// RENAME a mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_mailbox() {
    let conn = connect_dovecot().await;

    let old_name = &format!("test-rename-old-{}", std::process::id());
    let new_name = &format!("test-rename-new-{}", std::process::id());
    conn.create(old_name, TIMEOUT).await.unwrap();

    conn.rename(old_name, new_name, TIMEOUT).await.unwrap();

    // Old name should be gone, new name should exist
    let old_list = conn.list("", old_name, TIMEOUT).await.unwrap();
    assert!(old_list.is_empty(), "old name should be gone");

    let new_list = conn.list("", new_name, TIMEOUT).await.unwrap();
    assert!(!new_list.is_empty(), "new name should exist");

    conn.logout().await.unwrap();
}

/// CLOSE expunges deleted messages and deselects.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_close_mailbox() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-close-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append and mark deleted
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // CLOSE should expunge
    conn.close(TIMEOUT).await.unwrap();

    // Re-select and verify empty
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0, "CLOSE should have expunged the message");

    conn.logout().await.unwrap();
}

/// CLOSE deselects the mailbox and returns the connection to authenticated
/// state per RFC 3501 Section 6.4.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_close_returns_to_authenticated_state() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("close-state");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.close(TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "CLOSE must leave the connection in the authenticated state"
    );

    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// STATUS to check mailbox stats without selecting.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_mailbox() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-status-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Append a message (need to select, append, then deselect)
    conn.select(mbox, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.unselect(TIMEOUT).await.unwrap();

    // STATUS without selecting
    let items = conn
        .status(mbox, "(MESSAGES UIDNEXT)", TIMEOUT)
        .await
        .unwrap()
        .items;

    let has_messages = items
        .iter()
        .any(|i| matches!(i, daaki_imap::types::StatusItem::Messages(n) if *n >= 1));
    assert!(
        has_messages,
        "STATUS should show at least 1 message: {items:?}"
    );

    conn.logout().await.unwrap();
}

/// UID SEARCH for messages matching criteria.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-search-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append two messages, one with \Seen
    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Search for unseen messages
    let uids = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 1, "expected 1 unseen message, got {uids:?}");

    // Search for all messages
    let uids = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 2, "expected 2 total messages, got {uids:?}");

    conn.logout().await.unwrap();
}

/// CLOSE expunges deleted messages and deselects on GreenMail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_close_mailbox() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-close-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append and mark deleted
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // CLOSE should expunge
    conn.close(TIMEOUT).await.unwrap();

    // Re-select and verify empty
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0, "CLOSE should have expunged the message");

    conn.logout().await.unwrap();
}

/// CLOSE deselects the mailbox and returns the connection to authenticated
/// state.
///
/// RFC 3501 Section 6.4.2: after CLOSE, the mailbox is no longer selected and
/// the session returns to the authenticated state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_close_returns_to_authenticated_state() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-close-state-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.close(TIMEOUT).await.unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "CLOSE must leave the connection in the authenticated state"
    );

    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// STATUS on INBOX on GreenMail — check mailbox stats without selecting.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_status_mailbox() {
    let conn = connect_greenmail().await;

    // INBOX always exists; append a message so MESSAGES count is non-zero.
    conn.select("INBOX", TIMEOUT).await.unwrap();
    conn.append("INBOX", &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();

    // STATUS without selecting
    let items = conn
        .status("INBOX", "(MESSAGES UIDNEXT)", TIMEOUT)
        .await
        .unwrap()
        .items;

    let has_messages = items
        .iter()
        .any(|i| matches!(i, daaki_imap::types::StatusItem::Messages(n) if *n >= 1));
    assert!(
        has_messages,
        "STATUS should show at least 1 message: {items:?}"
    );

    conn.logout().await.unwrap();
}

/// STATUS on the selected mailbox must report counts without deselecting it
/// per RFC 3501 Section 6.3.10.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_status_on_selected_mailbox_preserves_selected_state() {
    let conn = connect_greenmail().await;

    let mbox = unique_mailbox_name("gm-status-selected");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let baseline_status = conn.status(&mbox, "(UIDNEXT)", TIMEOUT).await.unwrap();
    let baseline_uidnext = find_uidnext(&baseline_status.items)
        .expect("STATUS UIDNEXT should be present before APPEND");

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let status = conn
        .status(&mbox, "(MESSAGES UNSEEN UIDNEXT)", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        find_messages(&status.items),
        Some(2),
        "STATUS MESSAGES should report both appended messages"
    );
    let unseen = status.items.iter().find_map(|item| match item {
        StatusItem::Unseen(value) => Some(*value),
        _ => None,
    });
    assert_eq!(
        unseen,
        Some(1),
        "STATUS UNSEEN should report only the message without \\Seen"
    );
    assert!(
        find_uidnext(&status.items).is_some_and(|value| value >= baseline_uidnext + 2),
        "STATUS UIDNEXT should advance by both APPENDs: {status:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "STATUS must not deselect the mailbox"
    );

    let fetches = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        2,
        "connection should remain usable on the selected mailbox after STATUS"
    );

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &mbox).await;
    conn.logout().await.unwrap();
}

/// UID SEARCH for messages matching criteria on GreenMail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_uid_search() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-search-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append two messages, one with \Seen
    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Search for unseen messages
    let uids = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 1, "expected 1 unseen message, got {uids:?}");

    // Search for all messages
    let uids = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 2, "expected 2 total messages, got {uids:?}");

    conn.logout().await.unwrap();
}

/// SEARCH returns matching sequence numbers on GreenMail per RFC 3501 Section 6.4.4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_search_by_sequence() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-search-seq-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let seqs = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        seqs.ids,
        vec![1, 2],
        "expected SEARCH ALL to return sequence numbers 1 and 2, got {seqs:?}"
    );

    let unseen = conn.search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(
        unseen.ids,
        vec![2],
        "expected SEARCH UNSEEN to return only sequence number 2, got {unseen:?}"
    );

    conn.logout().await.unwrap();
}

/// FETCH returns message data by sequence number on GreenMail per RFC 3501 Section 6.4.5.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_fetch_by_sequence() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-fetch-seq-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags, FetchAttr::Rfc822Size],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one fetch result");
    assert_eq!(fetches[0].seq, 1);
    assert!(fetches[0].rfc822_size.is_some());
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(flags.contains(&Flag::Seen), "expected \\Seen flag");

    conn.logout().await.unwrap();
}

/// STORE updates flags by sequence number on GreenMail per RFC 3501 Section 6.4.6.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_store_by_sequence() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-store-seq-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "expected one FETCH result, got {fetches:?}"
    );
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Flagged),
        "expected \\Flagged after STORE, got {flags:?}"
    );

    conn.logout().await.unwrap();
}

/// STORE `-FLAGS` must remove only the requested flags on GreenMail per RFC
/// 3501 Section 6.4.6.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_store_remove_flag_by_sequence() {
    let conn = connect_greenmail().await;

    let mbox = unique_mailbox_name("gm-store-remove");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(
        &mbox,
        &[Flag::Seen, Flag::Flagged],
        None,
        TEST_MESSAGE,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Remove,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        !flags.contains(&Flag::Flagged),
        "\\Flagged should be removed"
    );
    assert!(flags.contains(&Flag::Seen), "\\Seen should remain");

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &mbox).await;
    conn.logout().await.unwrap();
}

/// STORE `FLAGS` must replace the existing flags on GreenMail per RFC 3501
/// Section 6.4.6.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_store_replace_flags_by_sequence() {
    let conn = connect_greenmail().await;

    let mbox = unique_mailbox_name("gm-store-replace");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(
        &mbox,
        &[Flag::Seen, Flag::Flagged],
        None,
        TEST_MESSAGE,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Replace,
        &[Flag::Answered],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(flags.contains(&Flag::Answered), "\\Answered should be set");
    assert!(!flags.contains(&Flag::Seen), "\\Seen should be replaced");
    assert!(
        !flags.contains(&Flag::Flagged),
        "\\Flagged should be replaced"
    );

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &mbox).await;
    conn.logout().await.unwrap();
}

/// COPY copies the requested message sequence set on GreenMail per RFC 3501 Section 6.4.7.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_copy_by_sequence() {
    let conn = connect_greenmail().await;

    let src = &format!("test-gm-copy-src-{}", std::process::id());
    let dst = &format!("test-gm-copy-dst-{}", std::process::id());
    conn.create(src, TIMEOUT).await.unwrap();
    conn.create(dst, TIMEOUT).await.unwrap();
    conn.select(src, TIMEOUT).await.unwrap();

    conn.append(src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.copy(&SequenceSet::new("1").unwrap(), dst, TIMEOUT)
        .await
        .unwrap();

    let selected = conn.select(dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 1,
        "expected exactly 1 copied message in {dst}, got {selected:?}"
    );

    conn.logout().await.unwrap();
}

/// COPY to the selected mailbox itself must append the duplicate to the end
/// of that mailbox on GreenMail per RFC 3501 Section 6.4.7.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_copy_by_sequence_to_same_mailbox_appends_duplicate_at_end() {
    let conn = connect_greenmail().await;

    let mailbox = unique_mailbox_name("gm-copy-same");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.append(&mailbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let before_copy = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        before_copy.len(),
        1,
        "test setup must create exactly one source message before COPY"
    );
    let source_uid = before_copy[0].uid.expect("source message must have UID");

    conn.copy(&SequenceSet::new("1").unwrap(), &mailbox, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        2,
        "same-mailbox COPY should leave both original and duplicate messages"
    );
    assert_eq!(fetches[0].seq, 1);
    assert_eq!(fetches[1].seq, 2);
    assert_eq!(fetches[0].uid, Some(source_uid));
    assert!(
        fetches[1].uid.is_some_and(|uid| uid > source_uid),
        "same-mailbox COPY must assign a fresh UID to the appended duplicate"
    );
    for fetch in &fetches {
        assert!(
            fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.contains(&Flag::Seen)),
            "COPY must preserve message flags in the destination mailbox"
        );
    }

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// UID COPY must preserve message flags in the destination mailbox on
/// GreenMail per RFC 3501 Section 6.4.7.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_uid_copy_preserves_flags() {
    let conn = connect_greenmail().await;

    let src = unique_mailbox_name("gm-uid-copy-src");
    let dst = unique_mailbox_name("gm-uid-copy-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(
        &src,
        &[Flag::Seen, Flag::Flagged],
        None,
        TEST_MESSAGE,
        TIMEOUT,
    )
    .await
    .unwrap();

    let source = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        source.len(),
        1,
        "test setup must create exactly one source message before UID COPY"
    );
    let source_uid = source[0].uid.expect("source message must have UID");

    conn.uid_copy(
        &SequenceSet::new(source_uid.to_string()).unwrap(),
        &dst,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.select(&dst, TIMEOUT).await.unwrap();
    let copied = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        copied.len(),
        1,
        "destination should contain exactly one copy"
    );
    let flags = copied[0]
        .flags
        .as_ref()
        .expect("copied message should return FLAGS");
    assert!(
        flags.contains(&Flag::Seen),
        "UID COPY should preserve \\Seen"
    );
    assert!(
        flags.contains(&Flag::Flagged),
        "UID COPY should preserve \\Flagged"
    );

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &dst).await;
    delete_mailbox_and_contents(&conn, &src).await;
    conn.logout().await.unwrap();
}

// ==========================================================================
// BODYSTRUCTURE fetch
// ==========================================================================

/// Append a message, fetch its BODYSTRUCTURE, and verify the parsed structure.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_bodystructure() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-bs-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::BodyStructure],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH response");

    let msg = &fetches[0];
    let bs = msg.body_structure.as_ref().expect("expected BODYSTRUCTURE");

    // TEST_MESSAGE is text/plain — should parse as a Text variant
    match bs {
        daaki_imap::types::BodyStructure::Text {
            media_subtype,
            encoding,
            ..
        } => {
            assert_eq!(media_subtype, "plain");
            // Dovecot may return 7BIT or 8BIT — normalized to lowercase.
            let enc_upper = encoding.to_ascii_uppercase();
            assert!(
                enc_upper == "7BIT" || enc_upper == "8BIT",
                "unexpected encoding: {encoding}"
            );
        }
        other => panic!("expected Text body structure, got {other:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// IDLE with actual EXISTS event
// ==========================================================================

/// Start IDLE on one connection, APPEND on another, verify EXISTS event.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_idle_exists_event() {
    let _guard = dovecot_idle_event_test_lock().lock().await;
    let user = unique_mailbox_name("idle-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = &format!("test-idle-exists-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Spawn a second connection that appends a message after a short delay
    let mbox_owned = mbox.to_owned();
    let appender = connect_dovecot_as(&user).await;
    let appender_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        appender
            .append(&mbox_owned, &[], None, TEST_MESSAGE, TIMEOUT)
            .await
            .unwrap();
        appender.logout().await.unwrap();
    });

    // Loop on idle() — Dovecot may send mailbox state updates (e.g.,
    // FLAGS) before the actual EXISTS notification.
    let deadline = std::time::Instant::now() + TIMEOUT;
    let mut got_exists = false;
    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = conn.idle(remaining, cancel).await.unwrap();
        match event {
            daaki_imap::IdleEvent::Exists(n) => {
                assert_eq!(n, 1, "expected EXISTS 1 after APPEND");
                got_exists = true;
                break;
            }
            daaki_imap::IdleEvent::Timeout => continue,
            _ => {} // Ignore unrelated events (FLAGS, RECENT, etc.)
        }
    }
    appender_handle.await.unwrap();
    assert!(got_exists, "did not observe EXISTS 1 within timeout");

    conn.logout().await.unwrap();
}

// ==========================================================================
// RFC 2047 encoded subject in FETCH
// ==========================================================================

/// Append a message with an RFC 2047 encoded subject, fetch it back, verify decoding.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rfc2047_encoded_subject() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-rfc2047-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Message with RFC 2047 base64-encoded UTF-8 subject: "Héllo Wörld"
    let encoded_message = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: =?UTF-8?B?SMOpbGxvIFfDtnJsZA==?=\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <test-rfc2047@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Body text.\r\n";

    conn.append(mbox, &[], None, encoded_message, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Envelope],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());

    let env = fetches[0].envelope.as_ref().expect("expected ENVELOPE");
    assert_eq!(
        env.subject.as_deref(),
        Some("H\u{e9}llo W\u{f6}rld"),
        "RFC 2047 subject should be decoded to UTF-8"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Body section fetch
// ==========================================================================

/// Append a message, fetch its body text via BODY[], verify content.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_body_section() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-body-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: false,
                    section: None,
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());

    let msg = &fetches[0];
    assert!(!msg.body_sections.is_empty(), "expected body section");
    let section = &msg.body_sections[0];
    let data = section.data.as_ref().expect("expected body data");
    let text = String::from_utf8_lossy(data);
    assert!(
        text.contains("Hello from daaki integration tests."),
        "body should contain the test message text"
    );

    conn.logout().await.unwrap();
}

/// Append a message, fetch its body text via BODY[] on GreenMail, verify content.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_fetch_body_section() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-body-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: false,
                    section: None,
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());

    let msg = &fetches[0];
    assert!(!msg.body_sections.is_empty(), "expected body section");
    let section = &msg.body_sections[0];
    let data = section.data.as_ref().expect("expected body data");
    let text = String::from_utf8_lossy(data);
    assert!(
        text.contains("Hello from daaki integration tests."),
        "body should contain the test message text"
    );

    conn.logout().await.unwrap();
}

/// `BODY.PEEK[]` must not set the `\\Seen` flag.
///
/// RFC 3501 Section 6.4.5 defines `BODY.PEEK[<section>]` as the FETCH variant
/// that does not implicitly set `\\Seen`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_fetch_body_peek_preserves_unseen() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-peek-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one appended message");
    let uid = fetches[0].uid.expect("expected UID in FETCH response");

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: true,
                    section: None,
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one FETCH response");
    assert!(
        !fetches[0].body_sections.is_empty(),
        "BODY.PEEK[] should return message data"
    );

    let unseen = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(
        unseen.ids,
        vec![uid],
        "BODY.PEEK[] must not mark the message as seen"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `BODY[]` must set the `\\Seen` flag.
///
/// RFC 3501 Section 6.4.5 defines `BODY[<section>]` without `PEEK` as the
/// FETCH form that implicitly sets `\\Seen`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_fetch_body_marks_seen() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-body-seen-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let unseen_before_fetch = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(
        unseen_before_fetch.ids.len(),
        1,
        "newly appended message should start unseen before BODY[]"
    );
    let uid = unseen_before_fetch.ids[0];

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: false,
                    section: None,
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one FETCH response");
    assert_eq!(fetches[0].uid, Some(uid), "FETCH response should match UID");
    assert!(
        !fetches[0].body_sections.is_empty(),
        "BODY[] should return message data"
    );

    let unseen = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
    assert!(
        unseen.ids.is_empty(),
        "BODY[] must mark the message as seen, got UNSEEN results {unseen:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// Error conditions
// ==========================================================================

/// Attempt LOGIN with wrong password — should return Error::Auth.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_login_invalid_credentials() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let result = conn.login(dovecot::USER, "wrong-password", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::Auth { .. })),
        "expected Error::Auth, got {result:?}"
    );
}

/// A failed LOGIN must leave the session Not Authenticated and allow a later
/// LOGIN retry on the same connection.
///
/// RFC 3501 Section 6.2.3 keeps the client in the Not Authenticated state
/// until LOGIN succeeds, so a tagged NO for bad credentials must not poison
/// the transport or consume the session.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_login_invalid_credentials_preserves_not_authenticated_state_and_allows_retry() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let result = conn
        .login(dovecot::USER, "wrong-password-retry", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::Auth { .. })),
        "expected Error::Auth for rejected LOGIN, got {result:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "failed LOGIN must leave the session in Not Authenticated state"
    );

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "successful retry LOGIN must authenticate the existing connection"
    );
    conn.noop(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// A failed LOGIN on GreenMail must leave the session Not Authenticated and
/// allow a later LOGIN retry on the same connection.
///
/// RFC 3501 Section 6.2.3 keeps the client in the Not Authenticated state
/// until LOGIN succeeds, so a tagged NO for bad credentials must not poison
/// the transport or consume the session.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_login_invalid_credentials_preserves_not_authenticated_state_and_allows_retry() {
    let conn = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAP_PORT,
        TlsMode::None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let result = conn
        .login(greenmail::USER, "wrong-password-retry", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::Auth { .. })),
        "expected Error::Auth for rejected LOGIN, got {result:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "failed LOGIN must leave the session in Not Authenticated state"
    );

    conn.login(greenmail::USER, greenmail::PASS, TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "successful retry LOGIN must authenticate the existing connection"
    );
    conn.noop(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SELECT a non-existent mailbox — should return Error::No.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_nonexistent_mailbox() {
    let conn = connect_dovecot().await;

    let result = conn.select("non-existent-mailbox-999", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// DELETE a non-existent mailbox — should return Error::No.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_nonexistent_mailbox() {
    let conn = connect_dovecot().await;

    let result = conn.delete("non-existent-mailbox-999", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No, got {result:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Error: MissingCapability (RFC 3501 Section 6.1.1)
// ==========================================================================

/// get_metadata requires METADATA capability — Dovecot doesn't advertise it
/// by default, so this should return Error::MissingCapability.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_missing_capability_metadata() {
    let conn = connect_dovecot().await;

    let result = conn
        .get_metadata("INBOX", &["/private/comment"], None, None, TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::MissingCapability(ref cap)) if cap == "METADATA"),
        "expected Error::MissingCapability(\"METADATA\"), got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// COMPRESS=DEFLATE capability check — if Dovecot doesn't advertise it, we get
/// Error::MissingCapability; if it does, compress() succeeds. Either way, no panic.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_compress_missing_or_succeeds() {
    let conn = connect_dovecot().await;

    let result = conn.compress(TIMEOUT).await;
    // Dovecot typically advertises COMPRESS=DEFLATE, so this may succeed.
    // If it doesn't, it must be MissingCapability — not some other error.
    match &result {
        Ok(()) => {} // capability was present, compress succeeded
        Err(daaki_imap::Error::MissingCapability(cap)) => {
            assert_eq!(cap, "COMPRESS=DEFLATE");
        }
        Err(other) => panic!("expected Ok or MissingCapability, got {other:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// Error: Auth (RFC 3501 Section 6.2.2)
// ==========================================================================

/// AUTHENTICATE PLAIN with wrong credentials — should return Error::Auth.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_authenticate_plain_bad_credentials() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let result = conn
        .authenticate_plain(dovecot::USER, "wrong-password-auth", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::Auth { .. })),
        "expected Error::Auth for bad AUTHENTICATE PLAIN, got {result:?}"
    );
}

/// AUTHENTICATE PLAIN with nonexistent user — should return Error::Auth.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_authenticate_plain_nonexistent_user() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let result = conn
        .authenticate_plain("no-such-user-999", "irrelevant", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::Auth { .. })),
        "expected Error::Auth for nonexistent user, got {result:?}"
    );
}

/// A failed AUTHENTICATE PLAIN must leave the session Not Authenticated and
/// allow a later LOGIN on the same TLS connection.
///
/// RFC 3501 Section 6.2.2 keeps the client in the Not Authenticated state
/// until AUTHENTICATE succeeds, and RFC 3501 Section 6.2.3 still allows a
/// later LOGIN once the server has rejected the earlier SASL exchange.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_authenticate_plain_bad_credentials_preserves_not_authenticated_state_and_allows_login(
) {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let result = conn
        .authenticate_plain(dovecot::USER, "wrong-password-retry", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::Auth { .. })),
        "expected Error::Auth for rejected AUTHENTICATE PLAIN, got {result:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "failed AUTHENTICATE PLAIN must leave the session in Not Authenticated state"
    );

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "successful LOGIN after failed AUTHENTICATE must authenticate the existing connection"
    );
    conn.noop(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// Error: No on operations (RFC 3501 Section 7.1.2)
// ==========================================================================

/// COPY to a nonexistent destination mailbox — should return Error::No.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_to_nonexistent_mailbox() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-copy-err-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    let result = conn
        .uid_copy(
            &SequenceSet::new(uid.to_string()).unwrap(),
            "nonexistent-dest-999",
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No for COPY to nonexistent mailbox, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// RENAME a nonexistent mailbox — should return Error::No.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_nonexistent_mailbox() {
    let conn = connect_dovecot().await;

    let result = conn
        .rename("nonexistent-src-999", "nonexistent-dst-999", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No for RENAME of nonexistent mailbox, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// CREATE a mailbox that already exists — should return Error::No.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_duplicate_mailbox() {
    let conn = connect_dovecot().await;

    // INBOX always exists — creating it again should fail.
    let result = conn.create("INBOX", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No for CREATE of existing mailbox, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// SELECT of a nonexistent mailbox returns tagged NO on GreenMail per RFC 3501 Sections 6.3.1 and 7.1.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_select_nonexistent_mailbox() {
    let conn = connect_greenmail().await;

    let result = conn.select("non-existent-mailbox-999", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// DELETE of a nonexistent mailbox returns tagged NO on GreenMail per RFC 3501 Sections 6.3.4 and 7.1.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_delete_nonexistent_mailbox() {
    let conn = connect_greenmail().await;

    let result = conn.delete("non-existent-mailbox-999", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// COPY to a nonexistent destination mailbox returns tagged NO on GreenMail per RFC 3501 Sections 6.4.7 and 7.1.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_copy_to_nonexistent_mailbox() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-copy-err-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let result = conn
        .copy(
            &SequenceSet::new("1").unwrap(),
            "nonexistent-dest-999",
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No for COPY to nonexistent mailbox, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// CREATE of an existing mailbox returns tagged NO on GreenMail per RFC 3501 Sections 6.3.3 and 7.1.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_create_duplicate_mailbox() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-create-duplicate-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    let result = conn.create(mbox, TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "expected Error::No for CREATE of existing mailbox, got {result:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Error: Bad — commands in wrong state (RFC 3501 Section 7.1.3)
// ==========================================================================

/// CLOSE without selecting a mailbox — CLOSE is only valid in SELECTED state
/// (RFC 3501 Section 6.4.2), so the server should respond with BAD.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_close_without_select() {
    let conn = connect_dovecot().await;

    // Don't select any mailbox — CLOSE should fail.
    // Client-side state check rejects before reaching the server.
    let result = conn.close(TIMEOUT).await;
    assert!(
        result.is_err(),
        "expected error for CLOSE without SELECT, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// EXPUNGE without selecting a mailbox — invalid in non-SELECTED state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_expunge_without_select() {
    let conn = connect_dovecot().await;

    // Client-side state check rejects before reaching the server.
    let result = conn.expunge(TIMEOUT).await;
    assert!(
        result.is_err(),
        "expected error for EXPUNGE without SELECT, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// SEARCH without selecting a mailbox — invalid in non-SELECTED state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_without_select() {
    let conn = connect_dovecot().await;

    // Client-side state check rejects before reaching the server.
    let result = conn.search("ALL", TIMEOUT).await;
    assert!(
        result.is_err(),
        "expected error for SEARCH without SELECT, got {result:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Operations after logout
// ==========================================================================

/// After logout, NOOP should fail — either with a client-side state check
/// (Protocol error) or a connection-level error (Closed/Io).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_noop_after_logout() {
    let conn = connect_dovecot().await;

    conn.logout().await.unwrap();

    // Client-side state check rejects in Logout state before reaching I/O
    // (RFC 3501 §3.4).
    let result = conn.noop(TIMEOUT).await;
    assert!(
        result.is_err(),
        "expected error for NOOP after logout, got {result:?}"
    );
}

/// After logout, SELECT should fail with Closed or Io error.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_after_logout() {
    let conn = connect_dovecot().await;

    conn.logout().await.unwrap();

    // Client-side state check rejects in Logout state before reaching I/O.
    let result = conn.select("INBOX", TIMEOUT).await;
    assert!(
        result.is_err(),
        "expected error for SELECT after logout, got {result:?}"
    );
}

/// After logout, LIST should fail with Closed or Io error.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_after_logout() {
    let conn = connect_dovecot().await;

    conn.logout().await.unwrap();

    // Client-side state check rejects in Logout state before reaching I/O.
    let result = conn.list("", "*", TIMEOUT).await;
    assert!(
        result.is_err(),
        "expected error for LIST after logout, got {result:?}"
    );
}

/// LOGOUT terminates the IMAP session, transitions the connection to the
/// logout state, and forbids subsequent commands.
///
/// RFC 3501 Section 6.1.3 requires the server to close the session, and
/// RFC 3501 Section 3.4 defines Logout as a terminal session state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_logout_transitions_to_logout_state_and_rejects_followup_commands() {
    let conn = connect_dovecot().await;

    assert_logout_transitions_to_logout_state_and_rejects_followup_commands(&conn).await;
}

/// LOGOUT terminates the IMAP session, transitions the connection to the
/// logout state, and forbids subsequent commands.
///
/// RFC 3501 Section 6.1.3 requires the server to close the session, and
/// RFC 3501 Section 3.4 defines Logout as a terminal session state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_logout_transitions_to_logout_state_and_rejects_followup_commands() {
    let conn = connect_greenmail().await;

    assert_logout_transitions_to_logout_state_and_rejects_followup_commands(&conn).await;
}

// ==========================================================================
// STARTTLS unavailability (RFC 3501 Section 6.2.1)
// ==========================================================================

/// On an already-TLS connection (implicit TLS), STARTTLS should be rejected
/// because the server won't advertise STARTTLS on an already-encrypted port.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_starttls_on_implicit_tls() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAPS_PORT,
        TlsMode::Implicit,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    // Server on the implicit TLS port should not advertise STARTTLS,
    // so the client should return StartTlsUnavailable or Protocol error.
    let result = conn.starttls_with_config(tls_config, TIMEOUT).await;
    assert!(
        matches!(
            result,
            Err(daaki_imap::Error::StartTlsUnavailable
                | daaki_imap::Error::Protocol(_))
        ),
        "expected Error::StartTlsUnavailable or Error::Protocol on implicit TLS port, got {result:?}"
    );

    // Connection should still be usable for login despite the failed STARTTLS attempt.
    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

/// Double STARTTLS — after upgrading once, a second attempt should fail because
/// the server no longer advertises STARTTLS after the first upgrade.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_double_starttls() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    // First STARTTLS should succeed.
    conn.starttls_with_config(tls_config.clone(), TIMEOUT)
        .await
        .unwrap();

    // Second STARTTLS should fail — STARTTLS is no longer advertised after upgrade.
    let result = conn.starttls_with_config(tls_config, TIMEOUT).await;
    assert!(
        matches!(
            result,
            Err(daaki_imap::Error::StartTlsUnavailable | daaki_imap::Error::Protocol(_))
        ),
        "expected Error::StartTlsUnavailable or Protocol on double STARTTLS, got {result:?}"
    );

    // Connection should still be usable.
    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// Complex BODYSTRUCTURE (multipart MIME)
// ==========================================================================

/// Append a multipart/mixed message with text/plain + attachment, verify BODYSTRUCTURE.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_bodystructure_multipart_mixed() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-bs-mixed-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // RFC 5322 message with multipart/mixed: text/plain + application/octet-stream attachment
    let message = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multipart mixed test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <test-mp-mixed@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"boundary-mixed-123\"\r\n\
\r\n\
--boundary-mixed-123\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Hello world\r\n\
--boundary-mixed-123\r\n\
Content-Type: application/octet-stream\r\n\
Content-Disposition: attachment; filename=\"test.bin\"\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVsbG8=\r\n\
--boundary-mixed-123--\r\n";

    conn.append(mbox, &[], None, message, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::BodyStructure],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH response");

    let bs = fetches[0]
        .body_structure
        .as_ref()
        .expect("expected BODYSTRUCTURE");

    match bs {
        daaki_imap::types::BodyStructure::Multipart {
            media_subtype,
            bodies,
            ..
        } => {
            assert_eq!(
                media_subtype.to_ascii_uppercase(),
                "MIXED",
                "expected multipart/mixed"
            );
            assert_eq!(bodies.len(), 2, "expected 2 parts in multipart/mixed");

            // First part should be text/plain
            match &bodies[0] {
                daaki_imap::types::BodyStructure::Text { media_subtype, .. } => {
                    assert_eq!(media_subtype.to_ascii_uppercase(), "PLAIN");
                }
                other => panic!("expected Text part for first body, got {other:?}"),
            }

            // Second part should be application/octet-stream (Basic)
            match &bodies[1] {
                daaki_imap::types::BodyStructure::Basic {
                    media_type,
                    media_subtype,
                    ..
                } => {
                    assert_eq!(media_type.to_ascii_uppercase(), "APPLICATION");
                    assert_eq!(media_subtype.to_ascii_uppercase(), "OCTET-STREAM");
                }
                other => panic!("expected Basic part for second body, got {other:?}"),
            }
        }
        other => panic!("expected Multipart body structure, got {other:?}"),
    }

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// Append a multipart/alternative message with text/plain + text/html, verify BODYSTRUCTURE.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_bodystructure_multipart_alternative() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-bs-alt-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // RFC 5322 message with multipart/alternative: text/plain + text/html
    let message = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multipart alternative test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <test-mp-alt@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/alternative; boundary=\"boundary-alt-456\"\r\n\
\r\n\
--boundary-alt-456\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Plain text version\r\n\
--boundary-alt-456\r\n\
Content-Type: text/html; charset=utf-8\r\n\
\r\n\
<html><body><p>HTML version</p></body></html>\r\n\
--boundary-alt-456--\r\n";

    conn.append(mbox, &[], None, message, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::BodyStructure],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH response");

    let bs = fetches[0]
        .body_structure
        .as_ref()
        .expect("expected BODYSTRUCTURE");

    match bs {
        daaki_imap::types::BodyStructure::Multipart {
            media_subtype,
            bodies,
            ..
        } => {
            assert_eq!(
                media_subtype.to_ascii_uppercase(),
                "ALTERNATIVE",
                "expected multipart/alternative"
            );
            assert_eq!(bodies.len(), 2, "expected 2 parts in multipart/alternative");

            // First part should be text/plain
            match &bodies[0] {
                daaki_imap::types::BodyStructure::Text { media_subtype, .. } => {
                    assert_eq!(media_subtype.to_ascii_uppercase(), "PLAIN");
                }
                other => panic!("expected Text/PLAIN for first part, got {other:?}"),
            }

            // Second part should be text/html
            match &bodies[1] {
                daaki_imap::types::BodyStructure::Text { media_subtype, .. } => {
                    assert_eq!(media_subtype.to_ascii_uppercase(), "HTML");
                }
                other => panic!("expected Text/HTML for second part, got {other:?}"),
            }
        }
        other => panic!("expected Multipart body structure, got {other:?}"),
    }

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// IDLE event coverage: FLAGS and EXPUNGE (RFC 2177)
// ==========================================================================

/// Start IDLE, change flags on another connection, verify FlagsChanged event.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_idle_flags_event() {
    let _guard = dovecot_idle_event_test_lock().lock().await;
    let user = unique_mailbox_name("idle-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = &format!("test-idle-flags-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Seed a message so we have something to STORE flags on.
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Re-select to see the appended message.
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Spawn a second connection that modifies flags.
    let mbox_owned = mbox.to_owned();
    let user_for_storer = user.clone();
    let storer_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let storer = connect_dovecot_as(&user_for_storer).await;
        storer.select(&mbox_owned, TIMEOUT).await.unwrap();
        // STORE \Flagged on message 1 (sequence number).
        storer
            .store(
                &SequenceSet::new("1").unwrap(),
                daaki_imap::types::StoreOperation::Add,
                &[Flag::Flagged],
                None,
                TIMEOUT,
            )
            .await
            .unwrap();
        storer.logout().await.unwrap();
    });

    // Loop on idle() — Dovecot may send mailbox state updates (e.g.,
    // FLAGS) before the actual FETCH notification.
    let deadline = std::time::Instant::now() + TIMEOUT;
    let mut got_fetch = false;
    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = conn.idle(remaining, cancel).await.unwrap();
        // RFC 2177 Section 3 / RFC 3501 Section 7.4.2: the server sends a
        // FETCH response during IDLE when flags change on a message.
        match &event {
            daaki_imap::IdleEvent::Fetch(fr) => {
                assert_eq!(fr.seq, 1, "expected FETCH for message sequence 1");
                let flags = fr.flags.as_ref().expect("FETCH should include FLAGS");
                assert!(
                    flags.contains(&Flag::Flagged),
                    "expected \\Flagged in FETCH flags, got {flags:?}"
                );
                got_fetch = true;
                break;
            }
            daaki_imap::IdleEvent::Timeout => continue,
            _ => {} // Ignore unrelated events (FLAGS, RECENT, etc.)
        }
    }
    storer_handle.await.unwrap();
    assert!(
        got_fetch,
        "did not observe FETCH with \\Flagged within timeout"
    );

    conn.logout().await.unwrap();
}

/// Start IDLE, expunge a message on another connection, verify Expunge event.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_idle_expunge_event() {
    let _guard = dovecot_idle_event_test_lock().lock().await;
    let user = unique_mailbox_name("idle-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = &format!("test-idle-expunge-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Seed a message so we can expunge it.
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Re-select to see the appended message.
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Spawn a second connection that marks \Deleted and EXPUNGEs.
    let mbox_owned = mbox.to_owned();
    let user_for_expunger = user.clone();
    let expunger_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let expunger = connect_dovecot_as(&user_for_expunger).await;
        expunger.select(&mbox_owned, TIMEOUT).await.unwrap();
        // Mark message 1 as \Deleted.
        expunger
            .store(
                &SequenceSet::new("1").unwrap(),
                daaki_imap::types::StoreOperation::Add,
                &[Flag::Deleted],
                None,
                TIMEOUT,
            )
            .await
            .unwrap();
        expunger.expunge(TIMEOUT).await.unwrap();
        expunger.logout().await.unwrap();
    });

    // Loop on idle() — Dovecot may send mailbox state updates (e.g.,
    // FLAGS) before the actual EXPUNGE or FETCH notification.
    let deadline = std::time::Instant::now() + TIMEOUT;
    let mut got_event = false;
    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = conn.idle(remaining, cancel).await.unwrap();
        // Dovecot may send a FETCH (flags change) or EXPUNGE first
        // depending on timing. Both are valid IDLE events triggered
        // by the delete+expunge sequence.
        match event {
            daaki_imap::IdleEvent::Expunge(_) | daaki_imap::IdleEvent::Fetch(_) => {
                got_event = true;
                break;
            }
            daaki_imap::IdleEvent::Timeout => continue,
            _ => {} // Ignore unrelated events (FLAGS, RECENT, etc.)
        }
    }
    expunger_handle.await.unwrap();
    assert!(
        got_event,
        "did not observe Expunge or Fetch event within timeout"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// NOOP (RFC 3501 Section 6.1.2)
// ==========================================================================

/// NOOP — simple keepalive, should succeed in authenticated state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_noop() {
    let conn = connect_dovecot().await;

    conn.noop(TIMEOUT).await.unwrap();

    conn.logout().await.unwrap();
}

/// NOOP in selected state — should also succeed.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_noop_selected() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.noop(TIMEOUT).await.unwrap();

    conn.logout().await.unwrap();
}

// ==========================================================================
// CHECK (RFC 3501 Section 6.4.1)
// ==========================================================================

/// CHECK — request a checkpoint in selected state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_check() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    conn.check(TIMEOUT).await.unwrap();

    conn.logout().await.unwrap();
}

/// CHECK in selected state must preserve the selected mailbox and leave it
/// usable for subsequent selected-state commands per RFC 3501 Section 6.4.1.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_check_selected_preserves_selected_state() {
    let conn = connect_greenmail().await;
    let mailbox = unique_mailbox_name("gm-check-selected");

    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.check(TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CHECK must keep the mailbox selected"
    );
    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after CHECK"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// CHECK after EXAMINE must preserve the selected read-only session and leave
/// the mailbox usable for subsequent selected-state commands.
///
/// RFC 3501 Section 6.3.2 defines EXAMINE as a read-only mailbox selection,
/// and RFC 3501 Section 6.4.1 defines CHECK as operating on the currently
/// selected mailbox rather than deselecting it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_check_after_examine_preserves_selected_state() {
    let conn = connect_dovecot().await;
    let mailbox = unique_mailbox_name("check-after-examine");

    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();

    let selected = conn.examine(&mailbox, TIMEOUT).await.unwrap();
    assert!(
        selected.read_only,
        "EXAMINE should keep the mailbox read-only before CHECK"
    );
    conn.check(TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CHECK after EXAMINE must keep the mailbox selected"
    );
    let store_result = conn
        .store(
            &SequenceSet::new("1").unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            None,
            TIMEOUT,
        )
        .await;
    match store_result {
        Ok(_) | Err(daaki_imap::Error::No { .. }) => {}
        Err(other) => panic!(
            "expected STORE after EXAMINE to be rejected or ignored without mutation, got {other:?}"
        ),
    }

    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "expected one message after CHECK + EXAMINE"
    );
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        !flags.contains(&Flag::Flagged),
        "CHECK must not make an EXAMINEd mailbox writable"
    );
    conn.logout().await.unwrap();
}

/// CHECK on a read-only EXAMINEd mailbox must keep that mailbox selected and
/// usable for subsequent selected-state commands.
///
/// RFC 3501 Section 6.3.2 defines EXAMINE as a read-only mailbox selection,
/// and RFC 3501 Section 6.4.1 defines CHECK as operating on the currently
/// selected mailbox rather than deselecting it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_check_after_examine_preserves_selected_state() {
    let conn = connect_greenmail().await;
    let mailbox = unique_mailbox_name("gm-check-after-examine");

    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();

    let selected = conn.examine(&mailbox, TIMEOUT).await.unwrap();
    assert!(
        selected.read_only,
        "EXAMINE should keep the mailbox read-only before CHECK"
    );
    conn.check(TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CHECK after EXAMINE must keep the mailbox selected"
    );
    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "the EXAMINEd mailbox must remain usable after CHECK"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// CHECK requests a checkpoint but must not expunge `\\Deleted` messages.
///
/// RFC 3501 Section 6.4.1 defines CHECK as mailbox housekeeping on the
/// currently selected mailbox; expunging requires CLOSE or EXPUNGE instead.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_check_does_not_expunge_deleted_messages() {
    let conn = connect_dovecot().await;

    assert_check_does_not_expunge_deleted_messages(&conn, "check-no-expunge").await;

    conn.logout().await.unwrap();
}

/// CHECK requests a checkpoint but must not expunge `\\Deleted` messages.
///
/// RFC 3501 Section 6.4.1 defines CHECK as mailbox housekeeping on the
/// currently selected mailbox; expunging requires CLOSE or EXPUNGE instead.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_check_does_not_expunge_deleted_messages() {
    let conn = connect_greenmail().await;

    assert_check_does_not_expunge_deleted_messages(&conn, "gm-check-no-expunge").await;

    conn.logout().await.unwrap();
}

// ==========================================================================
// UID EXPUNGE (RFC 4315 UIDPLUS)
// ==========================================================================

/// UID EXPUNGE — append two messages, mark one deleted, uid_expunge only that one.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_expunge() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-uid-expunge-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append two messages.
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Fetch UIDs of both messages.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(fetches.len() >= 2, "expected at least 2 messages");
    let uid_first = fetches[0].uid.unwrap();
    let uid_second = fetches[1].uid.unwrap();

    // Mark only the first message as \Deleted.
    conn.uid_store(
        &SequenceSet::new(uid_first.to_string()).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // UID EXPUNGE only the first message.
    conn.uid_expunge(&SequenceSet::new(uid_first.to_string()).unwrap(), TIMEOUT)
        .await
        .unwrap();

    // Re-select to get fresh EXISTS count.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 1,
        "only the first message should have been expunged"
    );

    // Verify the remaining message is the second one.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1);
    assert_eq!(fetches[0].uid.unwrap(), uid_second);

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// SUBSCRIBE / UNSUBSCRIBE / LSUB (RFC 3501 Sections 6.3.6-6.3.9)
// ==========================================================================

/// SUBSCRIBE, verify via LSUB, then UNSUBSCRIBE and verify removal.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_subscribe_lsub_unsubscribe() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-subscribe-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Subscribe to the mailbox.
    conn.subscribe(mbox, TIMEOUT).await.unwrap();

    // LSUB should include the mailbox.
    let subscribed = conn.lsub("", "*", TIMEOUT).await.unwrap();
    assert!(
        subscribed.iter().any(|m| m.name.as_str() == mbox.as_str()),
        "LSUB should include subscribed mailbox '{mbox}': {subscribed:?}"
    );

    // Unsubscribe.
    conn.unsubscribe(mbox, TIMEOUT).await.unwrap();

    // LSUB should no longer include the mailbox.
    let subscribed = conn.lsub("", mbox, TIMEOUT).await.unwrap();
    assert!(
        !subscribed.iter().any(|m| m.name.as_str() == mbox.as_str()),
        "LSUB should not include unsubscribed mailbox '{mbox}': {subscribed:?}"
    );

    // Clean up
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// LSUB with no subscriptions should return an empty list for a non-matching pattern.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_lsub_empty() {
    let conn = connect_dovecot().await;

    // Use a pattern that won't match any real mailbox.
    let subscribed = conn
        .lsub("", "nonexistent-pattern-999", TIMEOUT)
        .await
        .unwrap();
    assert!(
        subscribed.is_empty(),
        "LSUB with non-matching pattern should be empty: {subscribed:?}"
    );

    conn.logout().await.unwrap();
}

/// SUBSCRIBE adds the mailbox to LSUB and UNSUBSCRIBE removes it on GreenMail per RFC 3501 Sections 6.3.6, 6.3.7, and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_subscribe_lsub_unsubscribe() {
    let conn = connect_greenmail().await;

    let mbox = &format!("test-gm-subscribe-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    conn.subscribe(mbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", "*", TIMEOUT).await.unwrap();
    assert!(
        subscribed.iter().any(|m| m.name.as_str() == mbox.as_str()),
        "LSUB should include subscribed mailbox '{mbox}': {subscribed:?}"
    );

    conn.unsubscribe(mbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", mbox, TIMEOUT).await.unwrap();
    assert!(
        !subscribed.iter().any(|m| m.name.as_str() == mbox.as_str()),
        "LSUB should not include unsubscribed mailbox '{mbox}': {subscribed:?}"
    );

    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// LSUB returns an empty list when no subscriptions match on GreenMail per RFC 3501 Section 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_lsub_empty() {
    let conn = connect_greenmail().await;

    let subscribed = conn
        .lsub("", "nonexistent-pattern-999", TIMEOUT)
        .await
        .unwrap();
    assert!(
        subscribed.is_empty(),
        "LSUB with non-matching pattern should be empty: {subscribed:?}"
    );

    conn.logout().await.unwrap();
}

/// LSUB with a non-empty reference name must resolve a relative mailbox name
/// against that reference and return the canonical subscribed mailbox name per
/// RFC 3501 Sections 6.3.8 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_lsub_reference_resolves_relative_mailbox_name() {
    let conn = connect_greenmail().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_mailbox_name("gm-lsub-reference-parent");
    let child = format!("{parent}{delimiter}child");
    let reference = format!("{parent}{delimiter}");

    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();
    conn.subscribe(&child, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub(&reference, "child", TIMEOUT).await.unwrap();
    assert_eq!(
        subscribed.len(),
        1,
        "LSUB with a relative mailbox name should resolve to one canonical subscribed match: {subscribed:?}"
    );
    assert_eq!(subscribed[0].name.as_str(), child.as_str());

    conn.unsubscribe(&child, TIMEOUT).await.unwrap();
    conn.delete(&child, TIMEOUT).await.unwrap();
    conn.delete(&parent, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// LSUB with `%` under a reference name must match only subscribed direct
/// children of that reference, not deeper descendants, per RFC 3501 Sections
/// 6.3.8 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_lsub_reference_percent_matches_only_direct_children() {
    let conn = connect_greenmail().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_mailbox_name("gm-lsub-percent-parent");
    let child = format!("{parent}{delimiter}child");
    let grandchild = format!("{child}{delimiter}grandchild");
    let reference = format!("{parent}{delimiter}");

    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();
    conn.create(&grandchild, TIMEOUT).await.unwrap();
    conn.subscribe(&child, TIMEOUT).await.unwrap();
    conn.subscribe(&grandchild, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub(&reference, "%", TIMEOUT).await.unwrap();
    let mut names: Vec<_> = subscribed
        .iter()
        .map(|info| info.name.as_str().to_owned())
        .collect();
    names.sort();
    assert_eq!(
        names,
        vec![child.clone()],
        "LSUB % under a reference must return only direct subscribed children: {subscribed:?}"
    );

    conn.unsubscribe(&grandchild, TIMEOUT).await.unwrap();
    conn.unsubscribe(&child, TIMEOUT).await.unwrap();
    conn.delete(&grandchild, TIMEOUT).await.unwrap();
    conn.delete(&child, TIMEOUT).await.unwrap();
    conn.delete(&parent, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// LSUB with `%` under a non-empty reference name must match only subscribed
/// direct children of that reference, not deeper descendants, per RFC 3501
/// Sections 6.3.8 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_lsub_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_mailbox_name("dovecot-lsub-percent-parent");
    let child = format!("{parent}{delimiter}child");
    let grandchild = format!("{child}{delimiter}grandchild");
    let reference = format!("{parent}{delimiter}");

    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();
    conn.create(&grandchild, TIMEOUT).await.unwrap();
    conn.subscribe(&child, TIMEOUT).await.unwrap();
    conn.subscribe(&grandchild, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub(&reference, "%", TIMEOUT).await.unwrap();
    let mut names: Vec<_> = subscribed
        .iter()
        .map(|info| info.name.as_str().to_owned())
        .collect();
    names.sort();
    assert_eq!(
        names,
        vec![child.clone()],
        "LSUB % under a reference must return only direct subscribed children: {subscribed:?}"
    );

    conn.unsubscribe(&grandchild, TIMEOUT).await.unwrap();
    conn.unsubscribe(&child, TIMEOUT).await.unwrap();
    conn.delete(&grandchild, TIMEOUT).await.unwrap();
    conn.delete(&child, TIMEOUT).await.unwrap();
    conn.delete(&parent, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// NAMESPACE (RFC 2342)
// ==========================================================================

/// NAMESPACE — verify we get namespace information.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_namespace() {
    let conn = connect_dovecot().await;

    let ns = conn.namespace(TIMEOUT).await.unwrap();

    // Dovecot should return at least a personal namespace.
    assert!(
        !ns.personal.is_empty(),
        "expected at least one personal namespace, got empty"
    );

    // The personal namespace typically has a prefix (often "" or "INBOX.").
    let first = &ns.personal[0];
    // The delimiter should be present (commonly '.' or '/').
    assert!(
        first.delimiter.is_some(),
        "expected a hierarchy delimiter for the personal namespace"
    );

    // other and shared may be empty — that's valid.
    let _ = (ns.other, ns.shared);

    conn.logout().await.unwrap();
}

// ==========================================================================
// ID (RFC 2971)
// ==========================================================================

/// ID — send client identification, verify server responds.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_id() {
    let conn = connect_dovecot().await;

    let server_id = conn
        .id(
            &[("name", Some("daaki-test")), ("version", Some("0.1.0"))],
            TIMEOUT,
        )
        .await
        .unwrap();

    // Dovecot should return its identity (at least a "name" field).
    assert!(
        !server_id.is_empty(),
        "expected server to return ID information"
    );

    // Check that one of the returned fields is "name".
    let has_name = server_id
        .iter()
        .any(|(k, _)| k.eq_ignore_ascii_case("name"));
    assert!(
        has_name,
        "expected server ID to include a 'name' field: {server_id:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// ENABLE (RFC 5161)
// ==========================================================================

/// ENABLE CONDSTORE — verify the server confirms the extension.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_enable_condstore() {
    let conn = connect_dovecot().await;

    let enabled = conn.enable(&["CONDSTORE"], TIMEOUT).await.unwrap();

    // Dovecot supports CONDSTORE, so it should confirm it.
    assert!(
        enabled.iter().any(|s| s.eq_ignore_ascii_case("CONDSTORE")),
        "expected CONDSTORE to be enabled: {enabled:?}"
    );

    conn.logout().await.unwrap();
}

/// ENABLE with unsupported extension — server should return OK but not include it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_enable_unsupported() {
    let conn = connect_dovecot().await;

    let enabled = conn
        .enable(&["NONEXISTENT-EXTENSION"], TIMEOUT)
        .await
        .unwrap();

    // The server should not include an unsupported extension.
    assert!(
        !enabled
            .iter()
            .any(|s| s.eq_ignore_ascii_case("NONEXISTENT-EXTENSION")),
        "unsupported extension should not appear in ENABLED: {enabled:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// COMPRESS=DEFLATE (RFC 4978)
// ==========================================================================

/// COMPRESS=DEFLATE must leave the connection usable for subsequent commands.
///
/// RFC 4978 Sections 3 and 4 require the client and server to continue the
/// IMAP session over the compressed stream after successful `COMPRESS
/// DEFLATE`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_compress() {
    let conn = connect_dovecot().await;
    let mbox = unique_mailbox_name("test-compress-list");

    // Try to enable compression.
    match conn.compress(TIMEOUT).await {
        Ok(()) => {
            conn.create(&mbox, TIMEOUT).await.unwrap();

            // Compression is active — verify normal operations still work.
            // Use a mailbox created for this test rather than `*` over the full
            // namespace so the assertion stays bounded even when prior ignored
            // runs left many mailboxes behind on the shared Dovecot account.
            let mailboxes = conn.list("", &mbox, TIMEOUT).await.unwrap();
            assert!(
                mailboxes
                    .iter()
                    .any(|m| m.name.as_str().eq_ignore_ascii_case(&mbox)),
                "LIST over compressed connection should still find the test mailbox"
            );

            // SELECT also works.
            let selected = conn.select(&mbox, TIMEOUT).await.unwrap();
            assert!(selected.uid_validity.unwrap_or(0) > 0);

            // NOOP works over compressed connection.
            conn.noop(TIMEOUT).await.unwrap();

            conn.close(TIMEOUT).await.unwrap();
            conn.delete(&mbox, TIMEOUT).await.unwrap();
        }
        Err(daaki_imap::Error::MissingCapability(_)) => {
            // Server doesn't support COMPRESS=DEFLATE — that's acceptable.
        }
        Err(e) => panic!("unexpected error from COMPRESS: {e:?}"),
    }

    conn.logout().await.unwrap();
}

/// COMPRESS — append and fetch over a compressed connection.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_compress_append_fetch() {
    let conn = connect_dovecot().await;

    match conn.compress(TIMEOUT).await {
        Ok(()) => {
            let mbox = &format!("test-compress-af-{}", std::process::id());
            conn.create(mbox, TIMEOUT).await.unwrap();
            conn.select(mbox, TIMEOUT).await.unwrap();

            conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
                .await
                .unwrap();

            let fetches = conn
                .uid_fetch(
                    &SequenceSet::new("*").unwrap(),
                    &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::Envelope],
                    TIMEOUT,
                )
                .await
                .unwrap();
            assert!(
                !fetches.is_empty(),
                "expected FETCH response over compressed connection"
            );

            let msg = &fetches[0];
            if let Some(env) = &msg.envelope {
                assert_eq!(env.subject.as_deref(), Some("Integration test"));
            }

            // Clean up
            conn.close(TIMEOUT).await.unwrap();
            conn.delete(mbox, TIMEOUT).await.unwrap();
        }
        Err(daaki_imap::Error::MissingCapability(_)) => {
            // Server doesn't support COMPRESS=DEFLATE — skip.
        }
        Err(e) => panic!("unexpected error from COMPRESS: {e:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// QUOTA (RFC 2087)
// ==========================================================================

/// GETQUOTAROOT — retrieve quota roots for INBOX.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota_root() {
    let conn = connect_dovecot().await;

    match conn.get_quota_root("INBOX", TIMEOUT).await {
        Ok(result) => {
            // Dovecot with quota enabled should return at least one root.
            assert!(
                !result.roots.is_empty(),
                "expected at least one quota root for INBOX"
            );

            // Resources may include STORAGE.
            let _ = result.resources; // may be empty if no quota is configured
        }
        Err(
            daaki_imap::Error::No { .. }
            | daaki_imap::Error::Bad { .. }
            | daaki_imap::Error::MissingCapability(_),
        ) => {
            // Quota not configured or not advertised — acceptable.
        }
        Err(e) => panic!("unexpected error from GETQUOTAROOT: {e:?}"),
    }

    conn.logout().await.unwrap();
}

/// GETQUOTA — retrieve quota for a named root.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota() {
    let conn = connect_dovecot().await;

    // First discover quota roots via GETQUOTAROOT.
    match conn.get_quota_root("INBOX", TIMEOUT).await {
        Ok(result) if !result.roots.is_empty() => {
            let root = &result.roots[0];
            let resources = conn.get_quota(root, TIMEOUT).await.unwrap();

            // If quota is configured, resources should include at least STORAGE.
            let _ = resources; // may be empty depending on config
        }
        Ok(_) => {
            // No quota roots — try a common root name.
            match conn.get_quota("", TIMEOUT).await {
                Ok(resources) => {
                    let _ = resources;
                }
                Err(
                    daaki_imap::Error::No { .. }
                    | daaki_imap::Error::Bad { .. }
                    | daaki_imap::Error::MissingCapability(_),
                ) => {
                    // Quota not configured or not advertised — acceptable.
                }
                Err(e) => panic!("unexpected error from GETQUOTA: {e:?}"),
            }
        }
        Err(
            daaki_imap::Error::No { .. }
            | daaki_imap::Error::Bad { .. }
            | daaki_imap::Error::MissingCapability(_),
        ) => {
            // Quota not configured or not advertised — acceptable.
        }
        Err(e) => panic!("unexpected error from GETQUOTAROOT: {e:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// ACL (RFC 4314)
// ==========================================================================

/// GETACL / SETACL / DELETEACL / MYRIGHTS / LISTRIGHTS — full ACL lifecycle.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_acl_lifecycle() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-acl-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // MYRIGHTS — check the owner's rights on the mailbox.
    match conn.my_rights(mbox, TIMEOUT).await {
        Ok(rights) => {
            // Owner should have full rights (typically includes 'l', 'r', 's', 'w', etc.).
            assert!(
                !rights.is_empty(),
                "expected non-empty rights for mailbox owner"
            );
        }
        Err(
            daaki_imap::Error::No { .. }
            | daaki_imap::Error::Bad { .. }
            | daaki_imap::Error::MissingCapability(_),
        ) => {
            // ACL not supported or not advertised — clean up and return.
            conn.delete(mbox, TIMEOUT).await.unwrap();
            conn.logout().await.unwrap();
            return;
        }
        Err(e) => panic!("unexpected error from MYRIGHTS: {e:?}"),
    }

    // GETACL — retrieve current ACL entries.
    let acl_entries = conn.get_acl(mbox, TIMEOUT).await.unwrap();
    // Owner should have an ACL entry.
    assert!(
        !acl_entries.is_empty(),
        "expected at least one ACL entry for the owner"
    );

    // SETACL — grant read rights to a test identifier.
    let test_user = "testident";
    conn.set_acl(mbox, test_user, "lr", TIMEOUT).await.unwrap();

    // GETACL again — verify the new entry.
    let acl_entries = conn.get_acl(mbox, TIMEOUT).await.unwrap();
    let has_test_user = acl_entries.iter().any(|e| e.identifier == test_user);
    assert!(
        has_test_user,
        "expected ACL entry for '{test_user}' after SETACL: {acl_entries:?}"
    );

    // LISTRIGHTS — check what rights can be granted to the test user.
    let result = conn.list_rights(mbox, test_user, TIMEOUT).await.unwrap();
    // required and optional are strings of rights characters.
    let _ = result;

    // DELETEACL — remove the test user's rights.
    conn.delete_acl(mbox, test_user, TIMEOUT).await.unwrap();

    // Verify removal.
    let acl_entries = conn.get_acl(mbox, TIMEOUT).await.unwrap();
    let has_test_user = acl_entries.iter().any(|e| e.identifier == test_user);
    assert!(
        !has_test_user,
        "ACL entry for '{test_user}' should be gone after DELETEACL: {acl_entries:?}"
    );

    // Clean up
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// MYRIGHTS on INBOX — should always work if ACL is supported.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_my_rights_inbox() {
    let conn = connect_dovecot().await;

    match conn.my_rights("INBOX", TIMEOUT).await {
        Ok(rights) => {
            // INBOX owner should have at least lookup and read rights.
            assert!(
                rights.contains('l') || rights.contains('r'),
                "expected at least lookup or read rights on INBOX, got: '{rights}'"
            );
        }
        Err(
            daaki_imap::Error::No { .. }
            | daaki_imap::Error::Bad { .. }
            | daaki_imap::Error::MissingCapability(_),
        ) => {
            // ACL not enabled or not advertised — acceptable.
        }
        Err(e) => panic!("unexpected error from MYRIGHTS: {e:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// THREAD (RFC 5256)
// ==========================================================================

/// THREAD — thread messages using REFERENCES algorithm.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_thread() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-thread-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append a few messages to have something to thread.
    // Message 1: original
    let msg1 = b"\
From: alice@example.com\r\n\
To: bob@example.com\r\n\
Subject: Thread test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <thread-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Original message.\r\n";

    // Message 2: reply to message 1
    let msg2 = b"\
From: bob@example.com\r\n\
To: alice@example.com\r\n\
Subject: Re: Thread test\r\n\
Date: Sun, 15 Mar 2026 01:00:00 +0000\r\n\
Message-ID: <thread-2@daaki>\r\n\
In-Reply-To: <thread-1@daaki>\r\n\
References: <thread-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Reply to original.\r\n";

    conn.append(mbox, &[], None, msg1, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, msg2, TIMEOUT).await.unwrap();

    // Try THREAD with REFERENCES algorithm.
    match conn.thread("REFERENCES", "UTF-8", "ALL", TIMEOUT).await {
        Ok(threads) => {
            // With two related messages, we should get at least one thread.
            assert!(
                !threads.is_empty(),
                "expected at least one thread node for 2 related messages"
            );
        }
        Err(daaki_imap::Error::No { ref text, .. }) if text.contains("Unknown") => {
            // Server may not support THREAD — acceptable.
        }
        Err(daaki_imap::Error::Bad { .. }) => {
            // Server may not support THREAD command — acceptable.
        }
        Err(e) => panic!("unexpected error from THREAD: {e:?}"),
    }

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID THREAD — thread messages using UIDs.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_thread() {
    fn count_thread_nodes(nodes: &[daaki_imap::types::ThreadNode]) -> usize {
        nodes
            .iter()
            .map(|n| 1 + count_thread_nodes(&n.children))
            .sum()
    }

    let conn = connect_dovecot().await;

    let mbox = &format!("test-uid-thread-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append related messages.
    let msg1 = b"\
From: alice@example.com\r\n\
To: bob@example.com\r\n\
Subject: UID Thread test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <uid-thread-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Original.\r\n";

    let msg2 = b"\
From: bob@example.com\r\n\
To: alice@example.com\r\n\
Subject: Re: UID Thread test\r\n\
Date: Sun, 15 Mar 2026 01:00:00 +0000\r\n\
Message-ID: <uid-thread-2@daaki>\r\n\
In-Reply-To: <uid-thread-1@daaki>\r\n\
References: <uid-thread-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Reply.\r\n";

    conn.append(mbox, &[], None, msg1, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, msg2, TIMEOUT).await.unwrap();

    match conn.uid_thread("REFERENCES", "UTF-8", "ALL", TIMEOUT).await {
        Ok(threads) => {
            assert!(
                !threads.is_empty(),
                "expected at least one thread node from UID THREAD"
            );

            // Verify thread nodes contain messages (count all nodes in tree).
            let total = count_thread_nodes(&threads);
            assert!(
                total >= 2,
                "expected at least 2 messages in thread structure, got {total}"
            );
        }
        Err(daaki_imap::Error::No { ref text, .. }) if text.contains("Unknown") => {
            // Server may not support THREAD — acceptable.
        }
        Err(daaki_imap::Error::Bad { .. }) => {
            // Server may not support THREAD command — acceptable.
        }
        Err(e) => panic!("unexpected error from UID THREAD: {e:?}"),
    }

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// CONDSTORE (RFC 7162)
// ==========================================================================

/// CONDSTORE: SELECT returns HIGHESTMODSEQ when CONDSTORE is enabled.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_condstore_highestmodseq_in_select() {
    let conn = connect_dovecot().await;

    // Enable CONDSTORE.
    let enabled = conn.enable(&["CONDSTORE"], TIMEOUT).await.unwrap();
    assert!(
        enabled.iter().any(|s| s.eq_ignore_ascii_case("CONDSTORE")),
        "server must support CONDSTORE for this test"
    );

    // SELECT INBOX — should include HIGHESTMODSEQ.
    let selected = conn.select("INBOX", TIMEOUT).await.unwrap();
    assert!(
        selected.highest_mod_seq.is_some(),
        "expected HIGHESTMODSEQ in SELECT after enabling CONDSTORE"
    );

    conn.logout().await.unwrap();
}

/// CONDSTORE: FETCH with MODSEQ returns modseq values.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_condstore_fetch_modseq() {
    let conn = connect_dovecot().await;

    // Enable CONDSTORE.
    conn.enable(&["CONDSTORE"], TIMEOUT).await.unwrap();

    // Append a message to work with.
    let mbox = &format!("test-condstore-fetch-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.select(mbox, TIMEOUT).await.unwrap();

    // FETCH with MODSEQ — every message should have a modseq value.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags, FetchAttr::ModSeq],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH result");
    for fetch in &fetches {
        assert!(
            fetch.mod_seq.is_some(),
            "expected MODSEQ in FETCH response when CONDSTORE is enabled"
        );
    }

    conn.logout().await.unwrap();
}

/// CONDSTORE: STORE with UNCHANGEDSINCE succeeds when modseq matches.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_condstore_store_unchangedsince() {
    use daaki_imap::types::StoreOperation;

    let conn = connect_dovecot().await;

    conn.enable(&["CONDSTORE"], TIMEOUT).await.unwrap();

    let mbox = &format!("test-condstore-store-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.select(mbox, TIMEOUT).await.unwrap();

    // Fetch the current modseq.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags, FetchAttr::ModSeq],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());
    let first = &fetches[0];
    let uid = first.uid.expect("expected UID");
    let modseq = first.mod_seq.expect("expected MODSEQ");

    // STORE with matching UNCHANGEDSINCE should succeed.
    let result = conn
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            Some(modseq),
            TIMEOUT,
        )
        .await;
    assert!(result.is_ok(), "STORE with UNCHANGEDSINCE should succeed");

    conn.logout().await.unwrap();
}

// ==========================================================================
// MULTIAPPEND (RFC 3502)
// ==========================================================================

/// MULTIAPPEND — append multiple messages in a single command, verify all arrived.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
#[allow(clippy::too_many_lines)]
async fn dovecot_multi_append() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-multiappend-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Build 3 distinct messages for MULTIAPPEND.
    let msg1_data = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: MultiAppend message 1\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <multiappend-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
First message body.\r\n";

    let msg2_data = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: MultiAppend message 2\r\n\
Date: Sun, 15 Mar 2026 01:00:00 +0000\r\n\
Message-ID: <multiappend-2@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Second message body.\r\n";

    let msg3_data = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: MultiAppend message 3\r\n\
Date: Sun, 15 Mar 2026 02:00:00 +0000\r\n\
Message-ID: <multiappend-3@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Third message body.\r\n";

    let mut msg1 = daaki_imap::types::AppendMessage::new(msg1_data.to_vec());
    msg1.flags = vec![Flag::Seen];
    let msg2 = daaki_imap::types::AppendMessage::new(msg2_data.to_vec());
    let mut msg3 = daaki_imap::types::AppendMessage::new(msg3_data.to_vec());
    msg3.flags = vec![Flag::Flagged];
    let messages = vec![msg1, msg2, msg3];

    let append_uids = conn.multi_append(mbox, &messages, TIMEOUT).await.unwrap();

    // Verify APPENDUID response — Dovecot supports UIDPLUS so we should get UIDs.
    assert_eq!(
        append_uids.len(),
        3,
        "expected 3 APPENDUID entries, got {append_uids:?}"
    );

    // All uid_validity values should be the same and non-zero.
    let uid_validity = append_uids[0].0;
    assert!(uid_validity > 0, "UIDVALIDITY must be > 0");
    for &(uv, _) in &append_uids {
        assert_eq!(
            uv, uid_validity,
            "all APPENDUID entries should share the same UIDVALIDITY"
        );
    }

    // UIDs should be distinct.
    let uids: Vec<u32> = append_uids.iter().map(|&(_, uid)| uid).collect();
    let mut unique_uids = uids.clone();
    unique_uids.sort_unstable();
    unique_uids.dedup();
    assert_eq!(
        unique_uids.len(),
        3,
        "all 3 UIDs should be distinct: {uids:?}"
    );

    // Verify all 3 messages arrived via STATUS.
    let items = conn
        .status(mbox, "(MESSAGES)", TIMEOUT)
        .await
        .unwrap()
        .items;
    let msg_count = items.iter().find_map(|i| {
        if let daaki_imap::types::StatusItem::Messages(n) = i {
            Some(*n)
        } else {
            None
        }
    });
    assert_eq!(
        msg_count,
        Some(3),
        "expected 3 messages in mailbox after MULTIAPPEND: {items:?}"
    );

    // Verify individual messages via FETCH — check subjects and flags.
    conn.select(mbox, TIMEOUT).await.unwrap();
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::Envelope],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        3,
        "expected 3 FETCH responses, got {}",
        fetches.len()
    );

    // First message should have \Seen.
    if let Some(flags) = &fetches[0].flags {
        assert!(
            flags.contains(&Flag::Seen),
            "first message should have \\Seen flag"
        );
    }

    // Third message should have \Flagged.
    if let Some(flags) = &fetches[2].flags {
        assert!(
            flags.contains(&Flag::Flagged),
            "third message should have \\Flagged flag"
        );
    }

    // Verify subjects.
    let subjects: Vec<Option<&str>> = fetches
        .iter()
        .map(|f| f.envelope.as_ref().and_then(|e| e.subject.as_deref()))
        .collect();
    assert_eq!(subjects[0], Some("MultiAppend message 1"));
    assert_eq!(subjects[1], Some("MultiAppend message 2"));
    assert_eq!(subjects[2], Some("MultiAppend message 3"));

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// SELECT / EXAMINE with QRESYNC (RFC 7162)
// ==========================================================================

/// SELECT QRESYNC — select with QRESYNC parameters after enabling it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_qresync() {
    let conn = connect_dovecot().await;

    // QRESYNC requires ENABLE QRESYNC first (RFC 7162 Section 3.2.5).
    let enabled = conn.enable(&["QRESYNC"], TIMEOUT).await.unwrap();
    if !enabled.iter().any(|s| s.eq_ignore_ascii_case("QRESYNC")) {
        // Server doesn't support QRESYNC — skip test.
        conn.logout().await.unwrap();
        return;
    }

    let mbox = &format!("test-qresync-sel-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Append a message so the mailbox has content and a modseq.
    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // First SELECT to discover UIDVALIDITY and HIGHESTMODSEQ.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    let uid_validity = selected.uid_validity.expect("UIDVALIDITY must be present");
    assert!(uid_validity > 0, "UIDVALIDITY must be > 0");

    // HIGHESTMODSEQ should be present since QRESYNC implies CONDSTORE.
    let highest_mod_seq = selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ should be present when QRESYNC is enabled");
    assert!(highest_mod_seq > 0, "HIGHESTMODSEQ must be > 0");

    // Close and re-select with QRESYNC parameters.
    conn.close(TIMEOUT).await.unwrap();

    let params = daaki_imap::types::QresyncParams::new(uid_validity, highest_mod_seq);

    let qresync_selected = conn
        .select_with(
            mbox,
            &daaki_imap::types::SelectOptions::qresync(params),
            TIMEOUT,
        )
        .await
        .unwrap();

    // Verify HIGHESTMODSEQ is returned (QRESYNC implies CONDSTORE).
    assert!(
        qresync_selected.highest_mod_seq.is_some(),
        "SELECT QRESYNC should return HIGHESTMODSEQ"
    );

    // UIDVALIDITY should match what we got earlier.
    assert_eq!(
        qresync_selected.uid_validity,
        Some(uid_validity),
        "UIDVALIDITY should match between regular and QRESYNC SELECT"
    );

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// EXAMINE QRESYNC — read-only select with QRESYNC parameters.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_examine_qresync() {
    let conn = connect_dovecot().await;

    // QRESYNC requires ENABLE QRESYNC first (RFC 7162 Section 3.2.5).
    let enabled = conn.enable(&["QRESYNC"], TIMEOUT).await.unwrap();
    if !enabled.iter().any(|s| s.eq_ignore_ascii_case("QRESYNC")) {
        // Server doesn't support QRESYNC — skip test.
        conn.logout().await.unwrap();
        return;
    }

    let mbox = &format!("test-qresync-exam-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Append a message so the mailbox has content.
    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // First SELECT to discover UIDVALIDITY and HIGHESTMODSEQ.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    let uid_validity = selected.uid_validity.expect("UIDVALIDITY must be present");
    let highest_mod_seq = selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ should be present when QRESYNC is enabled");

    conn.close(TIMEOUT).await.unwrap();

    // EXAMINE with QRESYNC parameters.
    let params = daaki_imap::types::QresyncParams::new(uid_validity, highest_mod_seq);

    let examined = conn
        .examine_with(
            mbox,
            &daaki_imap::types::SelectOptions::qresync(params),
            TIMEOUT,
        )
        .await
        .unwrap();

    // Verify HIGHESTMODSEQ is returned.
    assert!(
        examined.highest_mod_seq.is_some(),
        "EXAMINE QRESYNC should return HIGHESTMODSEQ"
    );

    // EXAMINE opens read-only.
    assert!(
        examined.read_only,
        "EXAMINE QRESYNC should open mailbox read-only"
    );

    // UIDVALIDITY should match.
    assert_eq!(
        examined.uid_validity,
        Some(uid_validity),
        "UIDVALIDITY should match between SELECT and EXAMINE QRESYNC"
    );

    // Clean up (EXAMINE doesn't need CLOSE, but we can still use it)
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SELECT QRESYNC with VANISHED — verify server sends VANISHED for expunged messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_qresync_vanished() {
    let conn = connect_dovecot().await;

    // Enable QRESYNC (RFC 7162 Section 3.2.5).
    let enabled = conn.enable(&["QRESYNC"], TIMEOUT).await.unwrap();
    if !enabled.iter().any(|s| s.eq_ignore_ascii_case("QRESYNC")) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = &format!("test-qresync-vanish-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Append two messages.
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // First SELECT to get baseline UIDVALIDITY and HIGHESTMODSEQ.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    let uid_validity = selected.uid_validity.expect("UIDVALIDITY must be present");
    let mod_seq_before = selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ should be present");
    assert_eq!(selected.exists, 2, "expected 2 messages");

    // Expunge the first message.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uid_first = fetches[0].uid.unwrap();
    conn.uid_store(
        &SequenceSet::new(uid_first.to_string()).unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.expunge(TIMEOUT).await.unwrap();

    // Close the mailbox.
    conn.close(TIMEOUT).await.unwrap();

    // Re-select with QRESYNC using the old modseq — the server should
    // report that the first UID has vanished.
    let params = daaki_imap::types::QresyncParams::new(uid_validity, mod_seq_before);

    let qresync_selected = conn
        .select_with(
            mbox,
            &daaki_imap::types::SelectOptions::qresync(params),
            TIMEOUT,
        )
        .await
        .unwrap();

    // After expunging one of two messages, only 1 should remain.
    assert_eq!(
        qresync_selected.exists, 1,
        "expected 1 message after expunge"
    );

    // HIGHESTMODSEQ should have advanced past the pre-expunge value.
    let mod_seq_after = qresync_selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ should be present after QRESYNC SELECT");
    assert!(
        mod_seq_after >= mod_seq_before,
        "HIGHESTMODSEQ should be >= the pre-expunge value ({mod_seq_after} vs {mod_seq_before})"
    );

    // Clean up
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// LIST-EXTENDED / SPECIAL-USE (RFC 5258 / RFC 6154)
// ==========================================================================

/// LIST returns mailbox attributes including SPECIAL-USE flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_special_use_attributes() {
    let conn = connect_dovecot().await;

    // Dovecot typically returns SPECIAL-USE attributes in LIST responses
    // when configured (Trash, Sent, Drafts, Junk).
    let mailboxes = conn.list("", "*", TIMEOUT).await.unwrap();
    assert!(!mailboxes.is_empty(), "expected at least one mailbox");

    // Log all mailbox attributes for diagnostic purposes.
    for mb in &mailboxes {
        // Verify all mailbox names are non-empty.
        assert!(
            !mb.name.as_str().is_empty(),
            "mailbox name should not be empty"
        );
    }

    conn.logout().await.unwrap();
}

/// LIST-STATUS must return STATUS data for each matching mailbox without
/// selecting it.
///
/// RFC 5819 Section 2 adds `RETURN (STATUS (...))` to LIST and requires the
/// server to return the requested STATUS data for each matched mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status() {
    let conn = connect_dovecot().await;
    let prefix = unique_mailbox_name("list-status");
    let mailbox_a = format!("{prefix}-a");
    let mailbox_b = format!("{prefix}-b");
    let pattern = format!("{prefix}-*");

    conn.create(&mailbox_a, TIMEOUT).await.unwrap();
    conn.create(&mailbox_b, TIMEOUT).await.unwrap();
    conn.append(&mailbox_a, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_a, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let results = conn
        .list_status("", &pattern, "MESSAGES UNSEEN", TIMEOUT)
        .await
        .unwrap();

    assert_eq!(
        results.len(),
        2,
        "expected LIST-STATUS results for both created mailboxes: {results:?}"
    );

    let mut saw_mailbox_a = false;
    let mut saw_mailbox_b = false;
    for (mb, items) in &results {
        let messages = items.iter().find_map(|item| match item {
            StatusItem::Messages(value) => Some(*value),
            _ => None,
        });
        let unseen = items.iter().find_map(|item| match item {
            StatusItem::Unseen(value) => Some(*value),
            _ => None,
        });

        match mb.name.as_str() {
            name if name == mailbox_a => {
                saw_mailbox_a = true;
                assert_eq!(messages, Some(2), "mailbox A must report 2 messages");
                assert_eq!(unseen, Some(2), "mailbox A must report 2 unseen messages");
            }
            name if name == mailbox_b => {
                saw_mailbox_b = true;
                assert_eq!(messages, Some(0), "mailbox B must report 0 messages");
                assert_eq!(unseen, Some(0), "mailbox B must report 0 unseen messages");
            }
            other => panic!("unexpected mailbox in LIST-STATUS results: {other}"),
        }
    }

    assert!(saw_mailbox_a, "LIST-STATUS must include mailbox A");
    assert!(saw_mailbox_b, "LIST-STATUS must include mailbox B");

    conn.delete(&mailbox_a, TIMEOUT).await.unwrap();
    conn.delete(&mailbox_b, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// LIST-STATUS for an exact mailbox name must return that mailbox's STATUS
/// data without selecting it (RFC 5819 Section 2, RFC 3501 Section 6.3.10).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_exact_mailbox_reports_message_count() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("list-status-exact");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let results = conn
        .list_status("", &mailbox, "MESSAGES UIDNEXT", TIMEOUT)
        .await
        .unwrap();

    assert_eq!(
        results.len(),
        1,
        "exact mailbox LIST-STATUS should return one result: {results:?}"
    );
    let (info, items) = &results[0];
    assert_eq!(info.name.as_str(), mailbox.as_str());
    assert_eq!(
        find_messages(items),
        Some(2),
        "expected two appended messages"
    );
    assert!(
        find_uidnext(items).is_some_and(|uidnext| uidnext >= 3),
        "UIDNEXT should advance past the appended messages: {items:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// LIST-STATUS with a wildcard pattern must correlate each returned mailbox
/// with its own STATUS data (RFC 5819 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_wildcard_correlates_each_mailbox() {
    let conn = connect_dovecot().await;

    let prefix = unique_mailbox_name("list-status-wildcard");
    let mailbox_one = format!("{prefix}-one");
    let mailbox_two = format!("{prefix}-two");
    conn.create(&mailbox_one, TIMEOUT).await.unwrap();
    conn.create(&mailbox_two, TIMEOUT).await.unwrap();
    conn.append(&mailbox_one, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_two, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_two, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let pattern = format!("{prefix}*");
    let results = conn
        .list_status("", &pattern, "MESSAGES", TIMEOUT)
        .await
        .unwrap();

    let first = results
        .iter()
        .find(|(info, _)| info.name.as_str() == mailbox_one)
        .unwrap_or_else(|| panic!("missing LIST-STATUS entry for {mailbox_one}: {results:?}"));
    let second = results
        .iter()
        .find(|(info, _)| info.name.as_str() == mailbox_two)
        .unwrap_or_else(|| panic!("missing LIST-STATUS entry for {mailbox_two}: {results:?}"));

    assert_eq!(find_messages(&first.1), Some(1));
    assert_eq!(find_messages(&second.1), Some(2));

    delete_mailbox_and_contents(&conn, &mailbox_one).await;
    delete_mailbox_and_contents(&conn, &mailbox_two).await;
    conn.logout().await.unwrap();
}

/// STATUS-style listing must not disturb the currently selected mailbox;
/// a selected session remains selected after LIST-STATUS (RFC 3501 Section
/// 6.3.10, RFC 5819 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_preserves_selected_state() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("list-status-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select("INBOX", TIMEOUT).await.unwrap();

    let results = conn
        .list_status("", &mailbox, "MESSAGES", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(results.len(), 1, "expected one mailbox result: {results:?}");
    assert_eq!(results[0].0.name.as_str(), mailbox.as_str());
    assert_eq!(find_messages(&results[0].1), Some(0));

    conn.close(TIMEOUT)
        .await
        .expect("LIST-STATUS must not clear the selected state");
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// IMAP4rev1 mailbox names containing non-ASCII text and a literal `&` must
/// round-trip through CREATE and LIST.
///
/// RFC 3501 Section 5.1.3 requires non-ASCII mailbox names to use Modified
/// UTF-7 on the wire while preserving literal `&` as `&-`. RFC 3501 Section
/// 6.3.3 creates the mailbox, and RFC 3501 Section 7.2.2 returns the decoded
/// mailbox name in the LIST response.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_dovecot().await;

    let mailbox = unicode_mailbox_name("list-mutf7");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "exact LIST should return one mailbox for {mailbox:?}: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), mailbox.as_str());

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// IMAP4rev1 mailbox names containing non-ASCII text and a literal `&` must
/// round-trip through CREATE and LIST on GreenMail.
///
/// RFC 3501 Section 5.1.3 requires non-ASCII mailbox names to use Modified
/// UTF-7 on the wire while preserving literal `&` as `&-`. RFC 3501 Section
/// 6.3.3 creates the mailbox, and RFC 3501 Section 7.2.2 returns the decoded
/// mailbox name in the LIST response.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_list_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_greenmail().await;

    let mailbox = unicode_mailbox_name("gm-list-mutf7");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "exact LIST should return one mailbox for {mailbox:?}: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), mailbox.as_str());

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// RENAME must preserve the decoded user-facing mailbox name when both the
/// source and destination require Modified UTF-7 on the wire.
///
/// RFC 3501 Section 5.1.3 defines the mailbox-name wire encoding, RFC 3501
/// Section 6.3.5 renames the mailbox, and RFC 3501 Section 7.2.2 returns the
/// decoded mailbox name in subsequent LIST responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_round_trips_modified_utf7_mailbox_names() {
    let conn = connect_dovecot().await;

    let old_name = unicode_mailbox_name("rename-mutf7-old");
    let new_name = unicode_mailbox_name("rename-mutf7-new");
    conn.create(&old_name, TIMEOUT).await.unwrap();

    conn.rename(&old_name, &new_name, TIMEOUT).await.unwrap();

    let old_listing = conn.list("", &old_name, TIMEOUT).await.unwrap();
    assert!(
        !old_listing
            .iter()
            .any(|entry| entry.name.as_str() == old_name),
        "old mailbox name should no longer appear after RENAME: {old_listing:?}"
    );

    let new_listing = conn.list("", &new_name, TIMEOUT).await.unwrap();
    assert_eq!(
        new_listing.len(),
        1,
        "renamed mailbox should be listed exactly once: {new_listing:?}"
    );
    assert_eq!(new_listing[0].name.as_str(), new_name.as_str());

    delete_mailbox_and_contents(&conn, &new_name).await;
    conn.logout().await.unwrap();
}

/// RENAME must preserve the decoded user-facing mailbox name on GreenMail
/// when both the source and destination require Modified UTF-7 on the wire.
///
/// RFC 3501 Section 5.1.3 defines the mailbox-name wire encoding, RFC 3501
/// Section 6.3.5 renames the mailbox, and RFC 3501 Section 7.2.2 returns the
/// decoded mailbox name in subsequent LIST responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_rename_round_trips_modified_utf7_mailbox_names() {
    let conn = connect_greenmail().await;

    let old_name = unicode_mailbox_name("gm-rename-mutf7-old");
    let new_name = unicode_mailbox_name("gm-rename-mutf7-new");
    conn.create(&old_name, TIMEOUT).await.unwrap();

    conn.rename(&old_name, &new_name, TIMEOUT).await.unwrap();

    let old_listing = conn.list("", &old_name, TIMEOUT).await.unwrap();
    assert!(
        !old_listing
            .iter()
            .any(|entry| entry.name.as_str() == old_name),
        "old mailbox name should no longer appear after RENAME: {old_listing:?}"
    );

    let new_listing = conn.list("", &new_name, TIMEOUT).await.unwrap();
    assert_eq!(
        new_listing.len(),
        1,
        "renamed mailbox should be listed exactly once: {new_listing:?}"
    );
    assert_eq!(new_listing[0].name.as_str(), new_name.as_str());

    delete_mailbox_and_contents(&conn, &new_name).await;
    conn.logout().await.unwrap();
}

/// SUBSCRIBE and UNSUBSCRIBE must preserve the decoded mailbox name in LSUB
/// results when the mailbox name requires Modified UTF-7 on the wire.
///
/// RFC 3501 Section 5.1.3 defines mailbox-name encoding, RFC 3501 Sections
/// 6.3.6 and 6.3.7 update the subscription list, and RFC 3501 Section 6.3.9
/// returns subscribed mailbox names via LSUB.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_subscribe_unsubscribe_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_dovecot().await;

    let mailbox = unicode_mailbox_name("lsub-mutf7");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        subscribed.len(),
        1,
        "exact LSUB should return one subscribed mailbox: {subscribed:?}"
    );
    assert_eq!(subscribed[0].name.as_str(), mailbox.as_str());

    conn.unsubscribe(&mailbox, TIMEOUT).await.unwrap();

    let unsubscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert!(
        !unsubscribed
            .iter()
            .any(|entry| entry.name.as_str() == mailbox),
        "LSUB should omit the mailbox after UNSUBSCRIBE: {unsubscribed:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// SUBSCRIBE and UNSUBSCRIBE must preserve the decoded mailbox name in LSUB
/// results on GreenMail when the mailbox name requires Modified UTF-7 on the
/// wire.
///
/// RFC 3501 Section 5.1.3 defines mailbox-name encoding, RFC 3501 Sections
/// 6.3.6 and 6.3.7 update the subscription list, and RFC 3501 Section 6.3.9
/// returns subscribed mailbox names via LSUB.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_subscribe_unsubscribe_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_greenmail().await;

    let mailbox = unicode_mailbox_name("gm-lsub-mutf7");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        subscribed.len(),
        1,
        "exact LSUB should return one subscribed mailbox: {subscribed:?}"
    );
    assert_eq!(subscribed[0].name.as_str(), mailbox.as_str());

    conn.unsubscribe(&mailbox, TIMEOUT).await.unwrap();

    let unsubscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert!(
        !unsubscribed
            .iter()
            .any(|entry| entry.name.as_str() == mailbox),
        "LSUB should omit the mailbox after UNSUBSCRIBE: {unsubscribed:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// STATUS must accept a mailbox name that needs Modified UTF-7 on the wire and
/// report counts for that exact mailbox without selecting it.
///
/// RFC 3501 Section 5.1.3 defines mailbox-name encoding, RFC 3501 Section
/// 6.3.11 appends a message to the named mailbox, and RFC 3501 Section 6.3.10
/// reports STATUS for that mailbox without changing the selected state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_dovecot().await;

    let mailbox = unicode_mailbox_name("status-mutf7");
    let selected_mailbox = unique_mailbox_name("status-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let items = conn
        .status(&mailbox, "(MESSAGES UIDNEXT)", TIMEOUT)
        .await
        .unwrap()
        .items;

    assert_eq!(
        find_messages(&items),
        Some(1),
        "expected one message: {items:?}"
    );
    assert!(
        find_uidnext(&items).is_some_and(|uidnext| uidnext >= 2),
        "UIDNEXT should advance after APPEND: {items:?}"
    );

    let selected_fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert!(
        selected_fetches.is_empty(),
        "STATUS must not change the selected mailbox: {selected_fetches:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// STATUS must accept a mailbox name that needs Modified UTF-7 on the wire on
/// GreenMail and report counts for that exact mailbox without selecting it.
///
/// RFC 3501 Section 5.1.3 defines mailbox-name encoding, RFC 3501 Section
/// 6.3.11 appends a message to the named mailbox, and RFC 3501 Section 6.3.10
/// reports STATUS for that mailbox without changing the selected state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_status_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_greenmail().await;

    let mailbox = unicode_mailbox_name("gm-status-mutf7");
    let selected_mailbox = unique_mailbox_name("gm-status-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let items = conn
        .status(&mailbox, "(MESSAGES UIDNEXT)", TIMEOUT)
        .await
        .unwrap()
        .items;

    assert_eq!(
        find_messages(&items),
        Some(1),
        "expected one message: {items:?}"
    );
    assert!(
        find_uidnext(&items).is_some_and(|uidnext| uidnext >= 2),
        "UIDNEXT should advance after APPEND: {items:?}"
    );

    let selected_fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert!(
        selected_fetches.is_empty(),
        "STATUS must not change the selected mailbox: {selected_fetches:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// LIST-STATUS must correlate LIST and STATUS data using the decoded mailbox
/// name even when the mailbox name requires Modified UTF-7 on the wire.
///
/// RFC 3501 Section 5.1.3 defines mailbox-name encoding, RFC 3501 Section
/// 6.3.11 appends a message, and RFC 5819 Section 2 returns LIST and STATUS
/// data for the requested mailbox in one command.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_dovecot().await;

    let mailbox = unicode_mailbox_name("list-status-mutf7");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let results = conn
        .list_status("", &mailbox, "MESSAGES UIDNEXT", TIMEOUT)
        .await
        .unwrap();

    assert_eq!(
        results.len(),
        1,
        "exact LIST-STATUS should return one mailbox: {results:?}"
    );
    let (info, items) = &results[0];
    assert_eq!(info.name.as_str(), mailbox.as_str());
    assert_eq!(
        find_messages(items),
        Some(1),
        "expected one message: {items:?}"
    );
    assert!(
        find_uidnext(items).is_some_and(|uidnext| uidnext >= 2),
        "UIDNEXT should advance after APPEND: {items:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// APPEND and SELECT must target the same decoded mailbox name when the name
/// contains non-ASCII text and a literal `&`.
///
/// RFC 3501 Section 5.1.3 defines mailbox-name encoding, RFC 3501 Section
/// 6.3.11 appends to the named mailbox, RFC 3501 Section 6.3.1 selects it,
/// and RFC 3501 Section 6.4.8 allows UID FETCH to retrieve the appended
/// message once selected.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_and_uid_fetch_round_trips_modified_utf7_mailbox_name() {
    let conn = connect_dovecot().await;

    let mailbox = unicode_mailbox_name("select-mutf7");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let selected = conn.select(&mailbox, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 1, "SELECT should see the appended message");

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Rfc822Size],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "UID FETCH should return the appended message: {fetches:?}"
    );
    assert!(
        fetches[0].uid.is_some(),
        "UID should be present: {fetches:?}"
    );
    assert!(
        fetches[0].rfc822_size.is_some_and(|size| size > 0),
        "RFC822.SIZE should be present and positive: {fetches:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// CLOSE must expunge `\\Deleted` messages and return the session to the
/// authenticated state (RFC 3501 Section 6.4.2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_close_expunge_removes_deleted_messages() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("close-expunge");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.close(TIMEOUT).await.unwrap();

    let status = conn.status(&mailbox, "(MESSAGES)", TIMEOUT).await.unwrap();
    assert_eq!(
        find_messages(&status.items),
        Some(1),
        "CLOSE must expunge the deleted message"
    );

    conn.select(&mailbox, TIMEOUT)
        .await
        .expect("CLOSE must leave the connection authenticated, not logged out");
    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// STATUS with the `DELETED` item must report messages marked `\\Deleted`
/// without expunging them (RFC 9051 Section 6.3.11).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_deleted_reports_deleted_messages() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("status-deleted");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let status = match conn.status(&mailbox, "(MESSAGES DELETED)", TIMEOUT).await {
        Ok(status) => status,
        Err(daaki_imap::Error::MissingCapability(_)) => {
            eprintln!("skipping: server does not support STATUS DELETED");
            delete_mailbox_and_contents(&conn, &mailbox).await;
            conn.logout().await.unwrap();
            return;
        }
        Err(daaki_imap::Error::Protocol(message))
            if message.contains("STATUS item DELETED requires IMAP4rev2 or QUOTA=RES-MESSAGE") =>
        {
            eprintln!("skipping: server does not support STATUS DELETED");
            delete_mailbox_and_contents(&conn, &mailbox).await;
            conn.logout().await.unwrap();
            return;
        }
        Err(err) => panic!("unexpected STATUS DELETED error: {err:?}"),
    };
    assert_eq!(find_messages(&status.items), Some(2));
    assert_eq!(
        find_deleted(&status.items),
        Some(1),
        "STATUS DELETED must report one deleted-but-not-expunged message"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// STATUS with `HIGHESTMODSEQ` must reflect flag changes after a CONDSTORE
/// selection (RFC 7162 Section 3.1.2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_highestmodseq_advances_after_store() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("status-highestmodseq");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let selected = conn
        .select_with(&mailbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();
    let before = selected
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.close(TIMEOUT).await.unwrap();

    let status = conn
        .status(&mailbox, "(HIGHESTMODSEQ)", TIMEOUT)
        .await
        .unwrap();
    let after = find_highest_modseq(&status.items)
        .expect("STATUS HIGHESTMODSEQ must be present after CONDSTORE activity");
    assert!(
        after > before,
        "flag updates must advance HIGHESTMODSEQ ({before} -> {after})"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// SELECT should surface the same `MAILBOXID` returned by CREATE on servers
/// that advertise OBJECTID (RFC 8474 Sections 4.1 and 5.1).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_mailboxid_matches_create_mailboxid() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("select-mailboxid");
    let created_mailbox_id = match conn.create_with_mailbox_id(&mailbox, TIMEOUT).await {
        Ok(Some(mailbox_id)) => mailbox_id,
        Ok(None) | Err(daaki_imap::Error::MissingCapability(_)) => {
            eprintln!("skipping: server does not support OBJECTID / CREATE MAILBOXID");
            conn.logout().await.unwrap();
            return;
        }
        Err(err) => panic!("unexpected create_with_mailbox_id error: {err:?}"),
    };

    let selected = conn.select(&mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.mailbox_id.as_deref(),
        Some(created_mailbox_id.as_str()),
        "SELECT MAILBOXID must match the server-assigned mailbox identifier"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// STATUS with `MAILBOXID` should report the same stable object identifier as
/// SELECT for the same mailbox (RFC 8474 Section 5.1).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_mailboxid_matches_select_mailboxid() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("status-mailboxid");
    let created_mailbox_id = match conn.create_with_mailbox_id(&mailbox, TIMEOUT).await {
        Ok(Some(mailbox_id)) => mailbox_id,
        Ok(None) | Err(daaki_imap::Error::MissingCapability(_)) => {
            eprintln!("skipping: server does not support OBJECTID / CREATE MAILBOXID");
            conn.logout().await.unwrap();
            return;
        }
        Err(err) => panic!("unexpected create_with_mailbox_id error: {err:?}"),
    };

    let selected = conn.select(&mailbox, TIMEOUT).await.unwrap();
    let selected_mailbox_id = selected
        .mailbox_id
        .as_deref()
        .expect("SELECT should expose MAILBOXID on OBJECTID-capable servers");

    let status = conn.status(&mailbox, "(MAILBOXID)", TIMEOUT).await.unwrap();
    let status_mailbox_id = find_mailbox_id(&status.items)
        .expect("STATUS MAILBOXID must be present on OBJECTID-capable servers");

    assert_eq!(selected_mailbox_id, created_mailbox_id);
    assert_eq!(status_mailbox_id, created_mailbox_id.as_str());

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// LIST-EXTENDED accepts multiple mailbox patterns and must return each exact
/// match (RFC 5258 Section 3).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_multiple_patterns_return_exact_matches() {
    let conn = connect_dovecot().await;

    let mailbox_one = unique_mailbox_name("list-extended-one");
    let mailbox_two = unique_mailbox_name("list-extended-two");
    conn.create(&mailbox_one, TIMEOUT).await.unwrap();
    conn.create(&mailbox_two, TIMEOUT).await.unwrap();

    let results = conn
        .list_extended("", &[&mailbox_one, &mailbox_two], &[], &[], TIMEOUT)
        .await
        .unwrap();

    assert!(
        results.iter().any(|info| info.name.as_str() == mailbox_one),
        "LIST-EXTENDED should return the first exact-match mailbox: {results:?}"
    );
    assert!(
        results.iter().any(|info| info.name.as_str() == mailbox_two),
        "LIST-EXTENDED should return the second exact-match mailbox: {results:?}"
    );

    conn.delete(&mailbox_one, TIMEOUT).await.unwrap();
    conn.delete(&mailbox_two, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// With the CHILDREN return option, a mailbox that has a created child mailbox
/// must be marked with `\\HasChildren` (RFC 5258 Section 3.4, RFC 3348).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_children_marks_parent_with_child_mailbox() {
    let conn = connect_dovecot().await;

    let delimiter = conn
        .list("", "INBOX", TIMEOUT)
        .await
        .unwrap()
        .into_iter()
        .find_map(|info| info.delimiter)
        .unwrap_or('/');
    let parent = unique_mailbox_name("list-extended-parent");
    let child = format!("{parent}{delimiter}child");
    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();

    let results = conn
        .list_extended("", &[&parent], &[], &["CHILDREN"], TIMEOUT)
        .await
        .unwrap();

    let parent_info = results
        .iter()
        .find(|info| info.name.as_str() == parent)
        .unwrap_or_else(|| panic!("LIST-EXTENDED omitted parent mailbox {parent}: {results:?}"));
    assert!(
        parent_info
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::HasChildren)),
        "parent mailbox with a child must have \\\\HasChildren: {parent_info:?}"
    );

    conn.delete(&child, TIMEOUT).await.unwrap();
    conn.delete(&parent, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// XOAUTH2 authentication
// ==========================================================================

/// AUTHENTICATE XOAUTH2 with an invalid token returns an auth error.
///
/// Neither Dovecot nor GreenMail support XOAUTH2 in our Docker setup,
/// so we verify the error path (server rejects the mechanism or token).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_xoauth2_invalid_token_rejected() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    // Attempt XOAUTH2 with a dummy token — should fail.
    let result = conn
        .authenticate_xoauth2("testuser", "invalid-token", TIMEOUT)
        .await;
    assert!(
        result.is_err(),
        "XOAUTH2 with invalid token should be rejected"
    );
}

// ==========================================================================
// Non-UID command variants (sequence-number-based operations)
// ==========================================================================

/// SEARCH by sequence number returns matching sequence numbers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_by_sequence() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-search-seq-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Append two messages: one seen, one unseen.
    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let seqs = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(seqs.ids.len(), 2, "expected 2 messages, got {seqs:?}");

    let unseen = conn.search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(
        unseen.ids.len(),
        1,
        "expected 1 unseen message, got {unseen:?}"
    );

    conn.logout().await.unwrap();
}

/// FETCH by sequence number retrieves message data.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_by_sequence() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-fetch-seq-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Fetch by sequence number 1
    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags, FetchAttr::Rfc822Size],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one fetch result");
    assert_eq!(fetches[0].seq, 1);
    assert!(fetches[0].rfc822_size.is_some());
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(flags.contains(&Flag::Seen), "expected \\Seen flag");

    conn.logout().await.unwrap();
}

/// STORE by sequence number modifies flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_by_sequence() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-store-seq-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Store flags by sequence number
    conn.store(
        &SequenceSet::new("1").unwrap(),
        daaki_imap::types::StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Verify flags via fetch
    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Flagged),
        "expected \\Flagged after STORE, got {flags:?}"
    );

    conn.logout().await.unwrap();
}

/// COPY by sequence number copies messages to target mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_by_sequence() {
    let conn = connect_dovecot().await;

    let src = &format!("test-copy-src-{}", std::process::id());
    let dst = &format!("test-copy-dst-{}", std::process::id());
    conn.create(src, TIMEOUT).await.unwrap();
    conn.create(dst, TIMEOUT).await.unwrap();
    conn.select(src, TIMEOUT).await.unwrap();

    conn.append(src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Copy by sequence number
    conn.copy(&SequenceSet::new("1").unwrap(), dst, TIMEOUT)
        .await
        .unwrap();

    // Verify the target mailbox has the message
    let selected = conn.select(dst, TIMEOUT).await.unwrap();
    assert!(selected.exists >= 1, "expected at least 1 message in {dst}");

    conn.logout().await.unwrap();
}

// ==========================================================================
// SEARCHRES (RFC 5182) — search_save / uid_search_save
// ==========================================================================

/// UID SEARCH RETURN (SAVE) stores results server-side, then use $ to reference.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-searchres-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Save search results server-side — if the server doesn't support SEARCHRES
    // this will return an error, which is fine (we skip gracefully).
    if conn.uid_search_save("UNSEEN", TIMEOUT).await.is_err() {
        conn.logout().await.unwrap();
        return;
    }

    // Use $ to reference saved results in a FETCH
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("$").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "expected 1 unseen message from saved search, got {}",
        fetches.len()
    );

    conn.logout().await.unwrap();
}

/// search_save (non-UID) stores sequence numbers server-side.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-searchres2-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

    conn.append(mbox, &[Flag::Flagged], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    // Skip if server doesn't support SEARCHRES.
    if conn.search_save("FLAGGED", TIMEOUT).await.is_err() {
        conn.logout().await.unwrap();
        return;
    }

    conn.logout().await.unwrap();
}

/// SEARCH RETURN (SAVE) must let `$` drive a subsequent FETCH by sequence
/// number (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_fetches_saved_sequence_results() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-fetch-seq");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    let fetches = conn
        .fetch(
            &SequenceSet::new("$").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        fetches.len(),
        1,
        "saved SEARCH result should contain one message"
    );
    assert_eq!(
        fetches[0].seq, 2,
        "UNSEEN message should be sequence number 2"
    );
    assert!(
        !fetches[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen))),
        "FETCH $ should return the unseen message, not the seen one"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID SEARCH RETURN (SAVE) must let `$` drive a subsequent UID FETCH
/// (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_fetches_saved_uid_results() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-fetch-uid");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    let unseen_uid = conn
        .append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap()
        .expect("Dovecot should return APPENDUID for APPEND")
        .1;

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("$").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        fetches.len(),
        1,
        "saved UID SEARCH result should contain one message"
    );
    assert_eq!(fetches[0].uid, Some(unseen_uid));
    assert!(
        !fetches[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen))),
        "UID FETCH $ should return the unseen message, not the seen one"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// A second SAVE search must replace the prior `$` result rather than merge
/// with it (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_overwrites_previous_saved_result() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-overwrite");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[Flag::Flagged], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("SEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    conn.search_save("FLAGGED", TIMEOUT).await.unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("$").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        fetches.len(),
        1,
        "the most recent SAVE search should replace the previous result"
    );
    assert_eq!(
        fetches[0].seq, 2,
        "FLAGGED message should be sequence number 2"
    );
    assert!(
        fetches[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Flagged))),
        "FETCH $ should reflect the second SAVE search result"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// STORE with `$` must operate on the saved SEARCH result set
/// (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_store_uses_saved_result() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-store");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    conn.store(
        &SequenceSet::new("$").unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(fetches.len(), 3, "expected three messages after STORE");
    for fetch in fetches {
        let has_flagged = fetch
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Flagged)));
        match fetch.seq {
            1 => assert!(
                !has_flagged,
                "STORE $ must not touch the non-matching seen message"
            ),
            2 | 3 => assert!(has_flagged, "STORE $ must flag every saved unseen message"),
            other => panic!("unexpected sequence number returned from FETCH: {other}"),
        }
    }

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID COPY with `$` must copy exactly the saved UID result set
/// (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_copy_uses_saved_result() {
    let conn = connect_dovecot().await;

    let src = unique_mailbox_name("searchres-copy-src");
    let dst = unique_mailbox_name("searchres-copy-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    conn.uid_copy(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();

    conn.close(TIMEOUT).await.unwrap();
    let selected = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 1,
        "UID COPY $ must copy only the saved unseen message"
    );

    let copied = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        copied.len(),
        1,
        "destination mailbox should contain one copy"
    );
    assert!(
        !copied[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen))),
        "copied message should be the unseen source message"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// COPY with `$` must copy exactly the saved SEARCH result set by sequence
/// number (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_copy_uses_saved_result() {
    let conn = connect_dovecot().await;

    let src = unique_mailbox_name("searchres-seq-copy-src");
    let dst = unique_mailbox_name("searchres-seq-copy-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    conn.copy(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();

    conn.close(TIMEOUT).await.unwrap();
    let selected = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 2,
        "COPY $ must copy every saved unseen message"
    );

    let copied = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        copied.len(),
        2,
        "destination mailbox should contain two copies"
    );
    assert!(
        copied.iter().all(|fetch| {
            !fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen)))
        }),
        "COPY $ should preserve the saved unseen messages, not copy the seen one"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// MOVE with `$` must move exactly the saved SEARCH result set by sequence
/// number (RFC 5182 Section 2, RFC 6851 Section 3).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_move_uses_saved_result() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|cap| matches!(cap, Capability::Move)) {
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_mailbox_name("searchres-seq-move-src");
    let dst = unique_mailbox_name("searchres-seq-move-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    let moved = conn
        .move_messages(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();
    assert!(
        matches!(moved.expunged, daaki_imap::types::ExpungeResult::Expunged(ref seqs) if seqs.len() == 2),
        "MOVE $ must report both moved messages as expunged: {:?}",
        moved.expunged
    );

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_src.exists, 1,
        "MOVE $ must leave only the non-matching seen message in the source mailbox"
    );

    conn.close(TIMEOUT).await.unwrap();
    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 2,
        "MOVE $ must move every saved unseen message into the destination mailbox"
    );

    let moved_fetches = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        moved_fetches.len(),
        2,
        "destination mailbox should return both moved messages"
    );
    assert!(
        moved_fetches.iter().all(|fetch| {
            !fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen)))
        }),
        "MOVE $ should move only the unseen messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID STORE with `$` must operate on the saved UID SEARCH result set
/// (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_store_uses_saved_result() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-uid-store");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    conn.uid_store(
        &SequenceSet::new("$").unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(fetches.len(), 3, "expected three messages after UID STORE");
    let mut seen_only = 0;
    let mut flagged_unseen = 0;
    for fetch in fetches {
        let has_seen = fetch
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen)));
        let has_flagged = fetch
            .flags
            .as_ref()
            .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Flagged)));
        if has_seen {
            assert!(
                !has_flagged,
                "UID STORE $ must not touch the non-matching seen message"
            );
            seen_only += 1;
        } else {
            assert!(
                has_flagged,
                "UID STORE $ must flag every saved unseen message"
            );
            flagged_unseen += 1;
        }
    }
    assert_eq!(
        seen_only, 1,
        "exactly one seen message should remain unchanged after UID STORE $"
    );
    assert_eq!(
        flagged_unseen, 2,
        "UID STORE $ must flag exactly the two saved unseen messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID MOVE with `$` must move exactly the saved UID SEARCH result set
/// (RFC 5182 Section 2, RFC 6851 Section 3).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_move_uses_saved_result() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|cap| matches!(cap, Capability::Move)) {
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_mailbox_name("searchres-uid-move-src");
    let dst = unique_mailbox_name("searchres-uid-move-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    let moved = conn
        .uid_move_messages(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();
    assert!(
        matches!(moved.expunged, daaki_imap::types::ExpungeResult::Expunged(ref seqs) if seqs.len() == 2),
        "UID MOVE $ must report both moved messages as expunged: {:?}",
        moved.expunged
    );

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_src.exists, 1,
        "UID MOVE $ must leave only the non-matching seen message in the source mailbox"
    );

    conn.close(TIMEOUT).await.unwrap();
    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 2,
        "UID MOVE $ must move every saved unseen message into the destination mailbox"
    );

    let moved_fetches = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        moved_fetches.len(),
        2,
        "destination mailbox should return both moved messages"
    );
    assert!(
        moved_fetches.iter().all(|fetch| {
            !fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Seen)))
        }),
        "UID MOVE $ should move only the unseen messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SEARCH RETURN (SAVE) with no matches must make `$` behave as an empty
/// sequence set for subsequent FETCH commands (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_empty_result_fetch_returns_empty() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-empty-fetch");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    let fetches = conn
        .fetch(&SequenceSet::new("$").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert!(
        fetches.is_empty(),
        "FETCH $ after an empty SEARCH SAVE result must return no messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID SEARCH RETURN (SAVE) with no matches must make `$` behave as an empty
/// UID set for subsequent UID FETCH commands (RFC 5182 Section 2).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_empty_result_uid_fetch_returns_empty() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-empty-uid-fetch");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    let fetches = conn
        .uid_fetch(&SequenceSet::new("$").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert!(
        fetches.is_empty(),
        "UID FETCH $ after an empty UID SEARCH SAVE result must return no messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SEARCH RETURN (SAVE) with no matches must make `$` behave as a valid but
/// non-matching message set for subsequent STORE commands.
///
/// RFC 5182 Section 2.1 requires commands that accept message sets to treat an
/// empty `$` as valid and non-matching rather than as an error.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_empty_result_store_is_noop() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-empty-store");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    let stored = conn
        .store(
            &SequenceSet::new("$").unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        stored.fetches.is_empty(),
        "STORE $ against an empty saved result must not report FETCH updates"
    );

    let fetches = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "mailbox must still contain both messages");
    assert!(
        fetches.iter().all(|fetch| {
            !fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Flagged)))
        }),
        "STORE $ against an empty saved result must not modify any message flags"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID SEARCH RETURN (SAVE) with no matches must make `$` behave as a valid
/// but non-matching UID set for subsequent UID STORE commands.
///
/// RFC 5182 Section 2.1 requires commands that accept message sets to treat an
/// empty `$` as valid and non-matching rather than as an error.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_empty_result_uid_store_is_noop() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-empty-uid-store");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    let stored = conn
        .uid_store(
            &SequenceSet::new("$").unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        stored.fetches.is_empty(),
        "UID STORE $ against an empty saved result must not report FETCH updates"
    );

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "mailbox must still contain both messages");
    assert!(
        fetches.iter().all(|fetch| {
            !fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Flagged)))
        }),
        "UID STORE $ against an empty saved result must not modify any message flags"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SEARCH RETURN (SAVE) with no matches must let `COPY $` succeed while copying
/// no messages.
///
/// RFC 5182 Section 2.1 defines empty `$` as a valid non-matching message set,
/// and RFC 5182 Section 2.2 Example 6 shows `COPY $` succeeding with
/// "nothing copied".
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_empty_result_copy_copies_nothing() {
    let conn = connect_dovecot().await;

    let src = unique_mailbox_name("searchres-empty-copy-src");
    let dst = unique_mailbox_name("searchres-empty-copy-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    conn.copy(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_src.exists, 2,
        "COPY $ against an empty saved result must leave the source mailbox unchanged"
    );

    conn.close(TIMEOUT).await.unwrap();
    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 0,
        "COPY $ against an empty saved result must not create destination messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID SEARCH RETURN (SAVE) with no matches must let `UID COPY $` succeed
/// while copying no messages.
///
/// RFC 5182 Section 2.1 defines empty `$` as a valid non-matching message set,
/// and RFC 5182 Section 2.2 Example 6 shows `COPY $` succeeding with
/// "nothing copied".
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_empty_result_uid_copy_copies_nothing() {
    let conn = connect_dovecot().await;

    let src = unique_mailbox_name("searchres-empty-uid-copy-src");
    let dst = unique_mailbox_name("searchres-empty-uid-copy-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    conn.uid_copy(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_src.exists, 2,
        "UID COPY $ against an empty saved result must leave the source mailbox unchanged"
    );

    conn.close(TIMEOUT).await.unwrap();
    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 0,
        "UID COPY $ against an empty saved result must not create destination messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// SEARCH RETURN (SAVE) with no matches must let `MOVE $` succeed while moving
/// no messages.
///
/// RFC 5182 Section 2.1 defines empty `$` as a valid non-matching message set,
/// and RFC 6851 Section 3 defines MOVE's normal side effects only for matched
/// messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_save_empty_result_move_moves_nothing() {
    let conn = connect_dovecot().await;

    let src = unique_mailbox_name("searchres-empty-move-src");
    let dst = unique_mailbox_name("searchres-empty-move-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support SEARCH RETURN (SAVE)");

    let moved = conn
        .move_messages(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();
    assert!(
        matches!(
            moved.expunged,
            daaki_imap::types::ExpungeResult::Expunged(ref seqs) if seqs.is_empty()
        ) || matches!(
            moved.expunged,
            daaki_imap::types::ExpungeResult::Vanished(ref uids) if uids.is_empty()
        ),
        "MOVE $ against an empty saved result must not report EXPUNGE/VANISHED data: {:?}",
        moved.expunged
    );

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_src.exists, 2,
        "MOVE $ against an empty saved result must leave the source mailbox unchanged"
    );

    conn.close(TIMEOUT).await.unwrap();
    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 0,
        "MOVE $ against an empty saved result must not create destination messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID SEARCH RETURN (SAVE) with no matches must let `UID MOVE $` succeed
/// while moving no messages.
///
/// RFC 5182 Section 2.1 defines empty `$` as a valid non-matching message set,
/// and RFC 6851 Section 3 defines MOVE's normal side effects only for matched
/// messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_empty_result_uid_move_moves_nothing() {
    let conn = connect_dovecot().await;

    let src = unique_mailbox_name("searchres-empty-uid-move-src");
    let dst = unique_mailbox_name("searchres-empty-uid-move-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    let moved = conn
        .uid_move_messages(&SequenceSet::new("$").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();
    assert!(
        matches!(
            moved.expunged,
            daaki_imap::types::ExpungeResult::Expunged(ref seqs) if seqs.is_empty()
        ) || matches!(
            moved.expunged,
            daaki_imap::types::ExpungeResult::Vanished(ref uids) if uids.is_empty()
        ),
        "UID MOVE $ against an empty saved result must not report EXPUNGE/VANISHED data: {:?}",
        moved.expunged
    );

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_src.exists, 2,
        "UID MOVE $ against an empty saved result must leave the source mailbox unchanged"
    );

    conn.close(TIMEOUT).await.unwrap();
    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 0,
        "UID MOVE $ against an empty saved result must not create destination messages"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&src, TIMEOUT).await.unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UID SEARCH RETURN (SAVE) with no matches must let `UID EXPUNGE $` succeed
/// while expunging no messages, even when other `\\Deleted` messages exist.
///
/// RFC 5182 Section 2.1 defines empty `$` as a valid non-matching message set,
/// and RFC 4315 Section 2 limits UID EXPUNGE to the supplied UID set only.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_save_empty_result_uid_expunge_is_noop() {
    let conn = connect_dovecot().await;

    let mbox = unique_mailbox_name("searchres-empty-uid-expunge");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(
        &mbox,
        &[Flag::Seen, Flag::Deleted],
        None,
        TEST_MESSAGE,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.uid_search_save("UNSEEN", TIMEOUT)
        .await
        .expect("Dovecot should support UID SEARCH RETURN (SAVE)");

    let expunged = conn
        .uid_expunge(&SequenceSet::new("$").unwrap(), TIMEOUT)
        .await
        .unwrap();
    assert!(
        matches!(
            expunged,
            daaki_imap::types::ExpungeResult::Expunged(ref seqs) if seqs.is_empty()
        ) || matches!(
            expunged,
            daaki_imap::types::ExpungeResult::Vanished(ref uids) if uids.is_empty()
        ),
        "UID EXPUNGE $ against an empty saved result must not report EXPUNGE/VANISHED data: {expunged:?}"
    );

    let selected = conn.select(&mbox, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 2,
        "UID EXPUNGE $ against an empty saved result must not remove other deleted messages"
    );

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "mailbox must still contain both messages");
    assert!(
        fetches.iter().any(|fetch| {
            fetch
                .flags
                .as_ref()
                .is_some_and(|flags| flags.iter().any(|flag| matches!(flag, Flag::Deleted)))
        }),
        "the previously deleted message must remain present after UID EXPUNGE $ with an empty saved result"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// METADATA (RFC 5464) — get_metadata / set_metadata
// ==========================================================================

/// SETMETADATA and GETMETADATA on Dovecot.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_metadata_set_get_delete() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-metadata-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Set a private metadata entry — skip if server doesn't support METADATA.
    if conn
        .set_metadata(
            mbox,
            &[("/private/comment", Some(b"test value".as_slice()))],
            TIMEOUT,
        )
        .await
        .is_err()
    {
        let _ = conn.delete(mbox, TIMEOUT).await;
        conn.logout().await.unwrap();
        return;
    }

    // Get it back
    let entries = conn
        .get_metadata(mbox, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap()
        .entries;
    assert!(
        entries.iter().any(|e| e.name == "/private/comment"
            && e.value.as_deref() == Some(b"test value".as_slice())),
        "expected /private/comment = test value, got {entries:?}"
    );

    // Delete the entry (set value to None)
    conn.set_metadata(mbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();

    // Verify deletion
    let entries = conn
        .get_metadata(mbox, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap()
        .entries;
    let entry = entries.iter().find(|e| e.name == "/private/comment");
    if let Some(e) = entry {
        assert!(
            e.value.is_none(),
            "expected NIL value after delete, got {e:?}"
        );
    }

    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// Selected-state preservation and mailbox lifecycle gaps
// ==========================================================================

/// LIST in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.8.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = unique_mailbox_name("list-selected-source");
    let listed_mailbox = unique_mailbox_name("list-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&listed_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &listed_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "LIST should return the queried mailbox exactly once: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), listed_mailbox.as_str());

    let selected_fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        selected_fetches.len(),
        1,
        "LIST must leave the original mailbox selected: {selected_fetches:?}"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    delete_mailbox_and_contents(&conn, &listed_mailbox).await;
    conn.logout().await.unwrap();
}

/// LIST in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.8.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_list_selected_preserves_selected_mailbox() {
    let conn = connect_greenmail().await;

    let selected_mailbox = unique_mailbox_name("gm-list-selected-source");
    let listed_mailbox = unique_mailbox_name("gm-list-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&listed_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &listed_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "LIST should return the queried mailbox exactly once: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), listed_mailbox.as_str());

    let selected_fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        selected_fetches.len(),
        1,
        "LIST must leave the original mailbox selected: {selected_fetches:?}"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    delete_mailbox_and_contents(&conn, &listed_mailbox).await;
    conn.logout().await.unwrap();
}

/// LSUB in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_lsub_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = unique_mailbox_name("lsub-selected-source");
    let subscribed_mailbox = unique_mailbox_name("lsub-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&subscribed_mailbox, TIMEOUT).await.unwrap();
    conn.subscribe(&subscribed_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &subscribed_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        subscribed.len(),
        1,
        "LSUB should return the subscribed mailbox exactly once: {subscribed:?}"
    );
    assert_eq!(subscribed[0].name.as_str(), subscribed_mailbox.as_str());

    let selected_fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        selected_fetches.len(),
        1,
        "LSUB must leave the original mailbox selected: {selected_fetches:?}"
    );

    conn.unsubscribe(&subscribed_mailbox, TIMEOUT)
        .await
        .unwrap();
    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    delete_mailbox_and_contents(&conn, &subscribed_mailbox).await;
    conn.logout().await.unwrap();
}

/// LSUB in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_lsub_selected_preserves_selected_mailbox() {
    let conn = connect_greenmail().await;

    let selected_mailbox = unique_mailbox_name("gm-lsub-selected-source");
    let subscribed_mailbox = unique_mailbox_name("gm-lsub-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&subscribed_mailbox, TIMEOUT).await.unwrap();
    conn.subscribe(&subscribed_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &subscribed_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        subscribed.len(),
        1,
        "LSUB should return the subscribed mailbox exactly once: {subscribed:?}"
    );
    assert_eq!(subscribed[0].name.as_str(), subscribed_mailbox.as_str());

    let selected_fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        selected_fetches.len(),
        1,
        "LSUB must leave the original mailbox selected: {selected_fetches:?}"
    );

    conn.unsubscribe(&subscribed_mailbox, TIMEOUT)
        .await
        .unwrap();
    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    delete_mailbox_and_contents(&conn, &subscribed_mailbox).await;
    conn.logout().await.unwrap();
}

/// CAPABILITY is valid before authentication and must refresh the cached
/// capability snapshot without changing the session state per RFC 3501 Section
/// 6.1.1.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_capability_before_auth_updates_cache_without_authenticating() {
    init_tracing();
    let conn = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAP_PORT,
        TlsMode::None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let caps = conn.capability(TIMEOUT).await.unwrap();
    let mut returned: Vec<_> = caps.iter().map(Capability::as_imap_str).collect();
    returned.sort_unstable();
    let mut cached: Vec<_> = conn
        .capabilities()
        .iter()
        .map(Capability::as_imap_str)
        .collect();
    cached.sort_unstable();

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "CAPABILITY before LOGIN must not authenticate the session"
    );
    assert!(
        caps.contains(&Capability::Imap4Rev1),
        "CAPABILITY must include IMAP4rev1: {caps:?}"
    );
    assert_eq!(
        returned, cached,
        "explicit CAPABILITY must refresh the cached capability snapshot"
    );

    conn.logout().await.unwrap();
}

/// CAPABILITY in selected state must preserve the selected mailbox per
/// RFC 3501 Section 6.1.1.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_capability_selected_preserves_selected_mailbox() {
    let conn = connect_greenmail().await;

    let selected_mailbox = unique_mailbox_name("gm-capability-selected");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let capabilities = conn.capability(TIMEOUT).await.unwrap();
    assert!(
        capabilities.contains(&Capability::Imap4Rev1),
        "CAPABILITY must include IMAP4rev1: {capabilities:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CAPABILITY must not leave the session in a non-selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after CAPABILITY"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.logout().await.unwrap();
}

/// CREATE in selected state must not deselect the current mailbox per
/// RFC 3501 Section 6.3.3 and RFC 3501 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_create_selected_preserves_selected_mailbox() {
    let conn = connect_greenmail().await;

    let selected_mailbox = unique_mailbox_name("gm-create-selected-source");
    let created_mailbox = unique_mailbox_name("gm-create-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    conn.create(&created_mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &created_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "CREATE must make the new mailbox visible via LIST: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), created_mailbox.as_str());
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CREATE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after CREATE"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.delete(&created_mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// RENAME in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.5 and RFC 3501 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_rename_selected_preserves_selected_mailbox() {
    let conn = connect_greenmail().await;

    let selected_mailbox = unique_mailbox_name("gm-rename-selected-source");
    let old_name = unique_mailbox_name("gm-rename-selected-old");
    let new_name = unique_mailbox_name("gm-rename-selected-new");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&old_name, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    conn.rename(&old_name, &new_name, TIMEOUT).await.unwrap();

    let old_listing = conn.list("", &old_name, TIMEOUT).await.unwrap();
    assert!(
        old_listing.is_empty(),
        "RENAME must remove the old mailbox name from LIST: {old_listing:?}"
    );
    let new_listing = conn.list("", &new_name, TIMEOUT).await.unwrap();
    assert_eq!(
        new_listing.len(),
        1,
        "RENAME must expose the new mailbox name via LIST: {new_listing:?}"
    );
    assert_eq!(new_listing[0].name.as_str(), new_name.as_str());
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "RENAME must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after RENAME"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.delete(&new_name, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// DELETE in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.4 and RFC 3501 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_delete_selected_preserves_selected_mailbox() {
    let conn = connect_greenmail().await;

    let selected_mailbox = unique_mailbox_name("gm-delete-selected-source");
    let deleted_mailbox = unique_mailbox_name("gm-delete-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&deleted_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    conn.delete(&deleted_mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &deleted_mailbox, TIMEOUT).await.unwrap();
    assert!(
        listing.is_empty(),
        "DELETE must remove the mailbox from LIST results: {listing:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "DELETE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after DELETE"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.logout().await.unwrap();
}

/// SUBSCRIBE is valid in selected state and must update LSUB without
/// deselecting the mailbox per RFC 3501 Sections 6.3.6 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_subscribe_selected_mailbox_updates_lsub() {
    let conn = connect_greenmail().await;

    let mailbox = unique_mailbox_name("gm-subscribe-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert!(
        subscribed
            .iter()
            .any(|entry| entry.name.as_str() == mailbox),
        "LSUB should include a mailbox subscribed while selected: {subscribed:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "SUBSCRIBE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after SUBSCRIBE"
    );

    conn.unsubscribe(&mailbox, TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// UNSUBSCRIBE is valid in selected state and must update LSUB without
/// deselecting the mailbox per RFC 3501 Sections 6.3.7 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_unsubscribe_selected_mailbox_clears_lsub() {
    let conn = connect_greenmail().await;

    let mailbox = unique_mailbox_name("gm-unsubscribe-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.unsubscribe(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert!(
        !subscribed
            .iter()
            .any(|entry| entry.name.as_str() == mailbox),
        "LSUB should omit a mailbox unsubscribed while selected: {subscribed:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "UNSUBSCRIBE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after UNSUBSCRIBE"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// CAPABILITY in selected state must preserve the selected mailbox per
/// RFC 3501 Section 6.1.1.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_capability_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = unique_mailbox_name("capability-selected");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let capabilities = conn.capability(TIMEOUT).await.unwrap();
    assert!(
        capabilities.contains(&Capability::Imap4Rev1),
        "CAPABILITY must include IMAP4rev1: {capabilities:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CAPABILITY must not leave the session in a non-selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after CAPABILITY"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.logout().await.unwrap();
}

/// CREATE in selected state must not deselect the current mailbox per
/// RFC 3501 Section 6.3.3 and RFC 3501 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = unique_mailbox_name("create-selected-source");
    let created_mailbox = unique_mailbox_name("create-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    conn.create(&created_mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &created_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        listing.len(),
        1,
        "CREATE must make the new mailbox visible via LIST: {listing:?}"
    );
    assert_eq!(listing[0].name.as_str(), created_mailbox.as_str());
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CREATE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after CREATE"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.delete(&created_mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// RENAME in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.5 and RFC 3501 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = unique_mailbox_name("rename-selected-source");
    let old_name = unique_mailbox_name("rename-selected-old");
    let new_name = unique_mailbox_name("rename-selected-new");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&old_name, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    conn.rename(&old_name, &new_name, TIMEOUT).await.unwrap();

    let old_listing = conn.list("", &old_name, TIMEOUT).await.unwrap();
    assert!(
        old_listing.is_empty(),
        "RENAME must remove the old mailbox name from LIST: {old_listing:?}"
    );
    let new_listing = conn.list("", &new_name, TIMEOUT).await.unwrap();
    assert_eq!(
        new_listing.len(),
        1,
        "RENAME must expose the new mailbox name via LIST: {new_listing:?}"
    );
    assert_eq!(new_listing[0].name.as_str(), new_name.as_str());
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "RENAME must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after RENAME"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.delete(&new_name, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// DELETE in selected state must not deselect the current mailbox per RFC 3501
/// Section 6.3.4 and RFC 3501 Section 3.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = unique_mailbox_name("delete-selected-source");
    let deleted_mailbox = unique_mailbox_name("delete-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&deleted_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    conn.delete(&deleted_mailbox, TIMEOUT).await.unwrap();

    let listing = conn.list("", &deleted_mailbox, TIMEOUT).await.unwrap();
    assert!(
        listing.is_empty(),
        "DELETE must remove the mailbox from LIST results: {listing:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "DELETE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after DELETE"
    );

    delete_mailbox_and_contents(&conn, &selected_mailbox).await;
    conn.logout().await.unwrap();
}

/// SUBSCRIBE is valid in selected state and must update LSUB without
/// deselecting the mailbox per RFC 3501 Sections 6.3.6 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_subscribe_selected_mailbox_updates_lsub() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("subscribe-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert!(
        subscribed
            .iter()
            .any(|entry| entry.name.as_str() == mailbox),
        "LSUB should include a mailbox subscribed while selected: {subscribed:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "SUBSCRIBE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after SUBSCRIBE"
    );

    conn.unsubscribe(&mailbox, TIMEOUT).await.unwrap();
    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// UNSUBSCRIBE is valid in selected state and must update LSUB without
/// deselecting the mailbox per RFC 3501 Sections 6.3.7 and 6.3.9.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_unsubscribe_selected_mailbox_clears_lsub() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("unsubscribe-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.unsubscribe(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", &mailbox, TIMEOUT).await.unwrap();
    assert!(
        !subscribed
            .iter()
            .any(|entry| entry.name.as_str() == mailbox),
        "LSUB should omit a mailbox unsubscribed while selected: {subscribed:?}"
    );
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "UNSUBSCRIBE must not leave selected state"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after UNSUBSCRIBE"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// STATUS on a non-existent mailbox must fail with tagged NO per RFC 3501
/// Section 6.3.10.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_nonexistent_mailbox() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("status-missing");
    let result = conn.status(&mailbox, "(MESSAGES UIDNEXT)", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "STATUS on a missing mailbox must return Error::No, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// STATUS on a non-existent mailbox must fail with tagged NO per RFC 3501
/// Section 6.3.10.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_status_nonexistent_mailbox() {
    let conn = connect_greenmail().await;

    let mailbox = unique_mailbox_name("gm-status-missing");
    let result = conn.status(&mailbox, "(MESSAGES UIDNEXT)", TIMEOUT).await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "STATUS on a missing mailbox must return Error::No, got {result:?}"
    );

    conn.logout().await.unwrap();
}

/// CLOSE must preserve messages not marked `\\Deleted` per RFC 3501 Section
/// 6.4.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_close_without_deleted_preserves_messages() {
    let conn = connect_dovecot().await;

    let mailbox = unique_mailbox_name("close-preserve");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.close(TIMEOUT).await.unwrap();

    let status = conn
        .status(&mailbox, "(MESSAGES UIDNEXT)", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        find_messages(&status.items),
        Some(2),
        "CLOSE must not remove messages lacking \\Deleted: {status:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

/// CLOSE must preserve messages not marked `\\Deleted` per RFC 3501 Section
/// 6.4.2.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_close_without_deleted_preserves_messages() {
    let conn = connect_greenmail().await;

    let mailbox = unique_mailbox_name("gm-close-preserve");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    conn.close(TIMEOUT).await.unwrap();

    let status = conn
        .status(&mailbox, "(MESSAGES UIDNEXT)", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        find_messages(&status.items),
        Some(2),
        "CLOSE must not remove messages lacking \\Deleted: {status:?}"
    );

    delete_mailbox_and_contents(&conn, &mailbox).await;
    conn.logout().await.unwrap();
}

// ==========================================================================
// NOTIFY (RFC 5465) — against Dovecot
// ==========================================================================
//
// Dovecot supports RFC 5465 NOTIFY end-to-end. These tests cover:
//   - basic NOTIFY SET with selected/non-selected filters
//   - NOTIFY NONE cancellation
//   - the STATUS indicator's initial-snapshot delivery
//   - IDLE delivery of MailboxName events created by a second session
//   - NOTIFY-driven STATUS event delivery during IDLE
//   - the \NoAccess ACL-loss notification path
//
// NOTIFICATIONOVERFLOW is not exercised here — Dovecot's default
// configuration sets the threshold so high that triggering it within a
// reasonable test runtime is impractical. The unit tests in
// `crates/imap/src/connection/tests.rs` cover the protocol handling.

use daaki_imap::{
    IdleEvent, MailboxFilter, NotifyEvent, NotifyEventGroup, NotifySetParams, TypedEvent,
};

/// Generate a mailbox name guaranteed unique across:
/// - concurrent tests in the same process (per-call atomic counter)
/// - re-runs of the same test binary (nanosecond timestamp)
/// - host environments (process id)
///
/// `std::process::id()` alone is NOT sufficient: cargo test runs every test
/// in the same integration binary in the same process by default, so all
/// tests share one PID. Two tests using `format!("foo-{}", process::id())`
/// would collide.
fn unique_mailbox_name(prefix: &str) -> String {
    use std::sync::atomic::{AtomicU64, Ordering};
    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let n = COUNTER.fetch_add(1, Ordering::SeqCst);
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    format!("{prefix}-{}-{nanos}-{n}", std::process::id())
}

fn unicode_mailbox_name(prefix: &str) -> String {
    format!("{}-Café & 箱", unique_mailbox_name(prefix))
}

fn find_messages(items: &[StatusItem]) -> Option<u32> {
    items.iter().find_map(|item| match item {
        StatusItem::Messages(value) => Some(*value),
        _ => None,
    })
}

fn find_uidnext(items: &[StatusItem]) -> Option<u32> {
    items.iter().find_map(|item| match item {
        StatusItem::UidNext(value) => Some(*value),
        _ => None,
    })
}

fn find_deleted(items: &[StatusItem]) -> Option<u32> {
    items.iter().find_map(|item| match item {
        StatusItem::Deleted(value) => Some(*value),
        _ => None,
    })
}

fn find_highest_modseq(items: &[StatusItem]) -> Option<u64> {
    items.iter().find_map(|item| match item {
        StatusItem::HighestModSeq(value) => Some(*value),
        _ => None,
    })
}

fn find_mailbox_id(items: &[StatusItem]) -> Option<&str> {
    items.iter().find_map(|item| match item {
        StatusItem::MailboxId(value) => Some(value.as_str()),
        _ => None,
    })
}

async fn assert_check_does_not_expunge_deleted_messages(conn: &ImapConnection, prefix: &str) {
    let mailbox = unique_mailbox_name(prefix);
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uid = fetches.last().unwrap().uid.unwrap();

    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.check(TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CHECK must keep the mailbox selected"
    );
    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "CHECK must not expunge the message marked \\Deleted"
    );
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Deleted),
        "CHECK must not clear or expunge the \\Deleted flag"
    );

    delete_mailbox_and_contents(conn, &mailbox).await;
}

async fn assert_logout_transitions_to_logout_state_and_rejects_followup_commands(
    conn: &ImapConnection,
) {
    conn.logout().await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Logout,
        "LOGOUT must leave the connection in the logout state"
    );
    let result = conn.noop(TIMEOUT).await;
    assert!(
        result.is_err(),
        "commands after LOGOUT must fail, got {result:?}"
    );
}

async fn delete_mailbox_and_contents(conn: &ImapConnection, mailbox: &str) {
    let selected = conn.select(mailbox, TIMEOUT).await.unwrap();
    if selected.exists > 0 {
        conn.store(
            &SequenceSet::new("1:*").unwrap(),
            StoreOperation::Add,
            &[Flag::Deleted],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();
        conn.expunge(TIMEOUT).await.unwrap();
    }
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mailbox, TIMEOUT).await.unwrap();
}

/// Discover the server's hierarchy delimiter via LIST per RFC 3501 Section
/// 6.3.8.
async fn hierarchy_delimiter(conn: &ImapConnection) -> char {
    conn.list("", "INBOX", TIMEOUT)
        .await
        .unwrap()
        .into_iter()
        .find_map(|info| info.delimiter)
        .expect("GreenMail LIST should advertise a hierarchy delimiter")
}

/// Capability gate: Dovecot must advertise NOTIFY for these tests to be
/// meaningful. Skip the test (as a no-op pass) if it doesn't — we don't
/// want a server-config drift to look like a code regression.
async fn require_dovecot_notify(conn: &ImapConnection) -> bool {
    let has_notify = conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, daaki_imap::types::Capability::Notify));
    if !has_notify {
        eprintln!("dovecot: NOTIFY capability not advertised — skipping test");
    }
    has_notify
}

/// Serialize the drain-events Dovecot tests so they stay below the server's
/// per-user connection limit while still exercising multi-session behavior.
fn dovecot_drain_events_test_lock() -> &'static tokio::sync::Mutex<()> {
    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}

/// Serialize the Dovecot NOTIFY tests because many of them need a watcher
/// session plus a second actor session to trigger the unsolicited event.
fn dovecot_notify_test_lock() -> &'static tokio::sync::Mutex<()> {
    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}

/// Serialize the Dovecot IDLE event tests because each one needs a watcher
/// session and a second actor session before the expected event can arrive.
fn dovecot_idle_event_test_lock() -> &'static tokio::sync::Mutex<()> {
    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}

/// Flush pending unsolicited responses into the typed event queue.
///
/// RFC 3501 Section 6.1.2: `NOOP` allows the server to send pending mailbox
/// updates immediately on an otherwise quiet connection.
async fn flush_pending_unsolicited(conn: &ImapConnection) {
    let (noop_result,) = conn.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();
}

/// Repeatedly `NOOP`s and drains the event queue until a matching event arrives.
///
/// RFC 3501 Section 6.1.2: `NOOP` is a synchronization point for pending
/// untagged responses, so using it before `drain_events()` keeps these tests
/// deterministic without relying on arbitrary sleeps.
async fn drain_events_until<F>(
    conn: &ImapConnection,
    deadline: tokio::time::Instant,
    mut predicate: F,
) -> Vec<TypedEvent>
where
    F: FnMut(&TypedEvent) -> bool,
{
    let mut collected = Vec::new();
    while tokio::time::Instant::now() < deadline {
        flush_pending_unsolicited(conn).await;
        let batch = conn.drain_events().await;
        let matched = batch.iter().any(&mut predicate);
        collected.extend(batch);
        if matched {
            break;
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    collected
}

/// `NOTIFY SET (selected (MessageNew MessageExpunge))` then `NOTIFY NONE`
/// must both succeed against Dovecot (RFC 5465 Section 3).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_set_selected_then_none() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let conn = connect_dovecot().await;
    if !require_dovecot_notify(&conn).await {
        let _ = conn.logout().await;
        return;
    }
    conn.select("INBOX", TIMEOUT).await.unwrap();

    // Register selected-mailbox message events.
    conn.notify_set(
        NotifySetParams::new(
            vec![NotifyEventGroup::new(
                MailboxFilter::Selected,
                vec![
                    NotifyEvent::MessageNew {
                        fetch_attrs: vec![],
                    },
                    NotifyEvent::MessageExpunge,
                ],
            )],
            false,
        ),
        TIMEOUT,
    )
    .await
    .expect("notify_set selected should succeed");

    // Cancel everything via NOTIFY NONE.
    conn.notify_none(TIMEOUT)
        .await
        .expect("notify_none should succeed");

    conn.logout().await.unwrap();
}

/// `NOTIFY SET STATUS (personal (MessageNew MessageExpunge))` registers a
/// non-selected filter with the STATUS indicator (RFC 5465 Section 4). The
/// server MUST send initial STATUS responses for matched mailboxes before
/// the tagged OK; those responses surface through the typed event queue.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_set_status_indicator_delivers_initial_snapshot() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let conn = connect_dovecot().await;
    if !require_dovecot_notify(&conn).await {
        let _ = conn.logout().await;
        return;
    }
    // Use Authenticated state so the personal filter has a clear scope.
    // (We don't SELECT — NOTIFY's STATUS indicator is independent of the
    // selected mailbox.)
    conn.notify_set(
        NotifySetParams::new(
            vec![NotifyEventGroup::new(
                MailboxFilter::Personal,
                vec![
                    NotifyEvent::MessageNew {
                        fetch_attrs: vec![],
                    },
                    NotifyEvent::MessageExpunge,
                ],
            )],
            true,
        ),
        TIMEOUT,
    )
    .await
    .expect("notify_set with STATUS should succeed");

    // The server MUST have sent at least one initial STATUS response
    // (for INBOX, which always exists in the personal namespace).
    // MailboxStatus maps to TypedEvent::Extension in the event queue.
    let events = conn.drain_events().await;
    let saw_status = events.iter().any(|ev| {
        matches!(
            ev,
            TypedEvent::Extension(resp)
                if matches!(**resp, daaki_imap::types::UntaggedResponse::MailboxStatus { .. })
        )
    });
    assert!(
        saw_status,
        "NOTIFY SET STATUS must deliver an initial STATUS snapshot, \
         got: {events:?}"
    );

    conn.notify_none(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `NOTIFY SET (personal (MailboxName))` followed by `idle()` must surface
/// a `MailboxName` event when a second session creates a new mailbox in the
/// personal namespace (RFC 5465 Section 5.4).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_mailbox_create_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_mailbox_name("notify-user");
    let watcher = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set MailboxName should succeed");

    // Drain anything the server sent during NOTIFY SET (Dovecot may emit
    // unrelated initial state) so the IDLE wake is unambiguous.
    let _ = watcher.drain_events().await;

    // Spawn a second session to CREATE a unique mailbox after a brief
    // pause, then trigger the IDLE event. The mailbox name is unique
    // per-test-invocation (timestamp + atomic counter + PID) so concurrent
    // test runs and parallel tests in the same binary cannot collide.
    let mailbox_name = unique_mailbox_name("notify-create");
    let mailbox_name_for_creator = mailbox_name.clone();
    let user_for_creator = user.clone();
    let creator_handle = tokio::spawn(async move {
        // Give the watcher time to enter IDLE.
        tokio::time::sleep(Duration::from_millis(500)).await;
        let creator = connect_dovecot_as(&user_for_creator).await;
        creator
            .create(&mailbox_name_for_creator, TIMEOUT)
            .await
            .unwrap();
        // Keep the creator alive briefly so the server emits the
        // notification before we tear down.
        tokio::time::sleep(Duration::from_millis(200)).await;
        creator.logout().await.unwrap();
    });

    // Loop on idle() until we see the CREATE event for OUR specific
    // mailbox (strict equality, RFC 3501 Section 5.1 case-insensitive
    // comparison). Dovecot may emit unrelated MailboxEvents (e.g., for
    // INBOX or for parallel tests' mailboxes) first, so we re-enter IDLE
    // on each non-matching event with an outer deadline.
    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    let mut got_create_event = false;
    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = watcher
            .idle(remaining.min(Duration::from_secs(5)), cancel)
            .await
            .expect("idle should succeed");
        match event {
            IdleEvent::MailboxEvent(info)
                if info.name.as_str().eq_ignore_ascii_case(&mailbox_name) =>
            {
                got_create_event = true;
                break;
            }
            // Ignore unrelated MailboxEvents (e.g., for INBOX or other
            // parallel test mailboxes) and unrelated status updates —
            // they're legitimate NOTIFY traffic, just not the event we're
            // hunting for.
            IdleEvent::MailboxEvent(_)
            | IdleEvent::MailboxStatus { .. }
            | IdleEvent::Recent(_)
            | IdleEvent::Exists(_)
            | IdleEvent::ExtensionEvent(_)
            | IdleEvent::StatusUpdate { .. }
            | IdleEvent::Timeout => {}
            other => panic!("unexpected event during MailboxName watch: {other:?}"),
        }
    }
    creator_handle.await.unwrap();
    assert!(
        got_create_event,
        "did not observe a MailboxEvent for {mailbox_name} within the deadline"
    );

    // Best-effort cleanup. The watcher's NOTIFY registration is still
    // active; deleting via the watcher would require selecting first, so
    // do it from a fresh session.
    let cleanup = connect_dovecot_as(&user).await;
    let _ = cleanup.delete(&mailbox_name, TIMEOUT).await;
    cleanup.logout().await.unwrap();

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (personal (MailboxName))` followed by `idle()` must surface
/// a delete event whose `MailboxInfo` includes the `\\NonExistent` attribute.
///
/// RFC 5465 Section 5.4: mailbox deletion notifications are delivered as LIST
/// responses. RFC 9051 Section 7.2.2: deleted mailbox notifications use the
/// `\\NonExistent` mailbox attribute.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_mailbox_delete_event_marks_nonexistent() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mailbox_name = unique_mailbox_name("notify-delete");
    watcher.create(&mailbox_name, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set MailboxName should succeed");
    let _ = watcher.drain_events().await;

    let mailbox_name_for_deleter = mailbox_name.clone();
    let deleter_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        let deleter = connect_dovecot().await;
        deleter
            .delete(&mailbox_name_for_deleter, TIMEOUT)
            .await
            .unwrap();
        tokio::time::sleep(Duration::from_millis(200)).await;
        deleter.logout().await.unwrap();
    });

    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    let mut observed = Vec::new();
    let mut saw_delete = false;
    while std::time::Instant::now() < deadline && !saw_delete {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = watcher
            .idle(remaining.min(Duration::from_secs(5)), cancel)
            .await
            .expect("idle should succeed");
        observed.push(format!("{event:?}"));
        match event {
            IdleEvent::MailboxEvent(info)
                if info.name.as_str().eq_ignore_ascii_case(&mailbox_name)
                    && info
                        .attributes
                        .iter()
                        .any(|attr| matches!(attr, MailboxAttribute::NonExistent)) =>
            {
                saw_delete = true;
            }
            IdleEvent::MailboxEvent(_)
            | IdleEvent::ExtensionEvent(_)
            | IdleEvent::StatusUpdate { .. }
            | IdleEvent::Timeout => {}
            other => panic!("unexpected event during delete watch: {other:?}"),
        }
    }

    deleter_handle.await.unwrap();
    assert!(
        saw_delete,
        "delete must surface a MailboxEvent with the \\\\NonExistent attribute; observed={observed:?}"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (personal (MailboxName))` must enqueue mailbox creation
/// notifications that can be read via `next_event()`.
///
/// RFC 5465 Section 5.4: mailbox create notifications are delivered as LIST
/// responses for the newly created mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_next_event_mailbox_create_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set MailboxName should succeed");
    let _ = watcher.drain_events().await;

    let mailbox_name = unique_mailbox_name("notify-next-create");
    let mailbox_name_for_creator = mailbox_name.clone();
    let creator_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let creator = connect_dovecot().await;
        creator
            .create(&mailbox_name_for_creator, TIMEOUT)
            .await
            .unwrap();
        creator.logout().await.unwrap();
    });

    creator_handle.await.unwrap();
    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
    let mut saw_create = false;
    while tokio::time::Instant::now() < deadline && !saw_create {
        if let Some(daaki_imap::TypedEvent::MailboxEvent(info)) = watcher
            .next_event(Duration::from_millis(500))
            .await
            .unwrap()
        {
            if info.name.as_str().eq_ignore_ascii_case(&mailbox_name) {
                assert!(
                    info.old_name.is_none(),
                    "create notifications must not carry OLDNAME: {info:?}"
                );
                assert!(
                    !info
                        .attributes
                        .iter()
                        .any(|attr| matches!(attr, MailboxAttribute::NonExistent)),
                    "create notifications must not mark the mailbox as \\\\NonExistent: {info:?}"
                );
                saw_create = true;
            }
        }
    }

    assert!(
        saw_create,
        "create must surface a MailboxEvent for the new mailbox via next_event"
    );

    let cleanup = connect_dovecot().await;
    let _ = cleanup.delete(&mailbox_name, TIMEOUT).await;
    cleanup.logout().await.unwrap();

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (personal (MailboxName))` must enqueue mailbox deletion
/// notifications that can be read via `next_event()`.
///
/// RFC 5465 Section 5.4: mailbox deletion notifications are delivered as LIST
/// responses. RFC 9051 Section 7.2.2: deleted mailbox notifications carry the
/// `\\NonExistent` attribute.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_next_event_mailbox_delete_event_marks_nonexistent() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mailbox_name = unique_mailbox_name("notify-next-delete");
    watcher.create(&mailbox_name, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set MailboxName should succeed");
    let _ = watcher.drain_events().await;

    let mailbox_name_for_deleter = mailbox_name.clone();
    let deleter_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let deleter = connect_dovecot().await;
        deleter
            .delete(&mailbox_name_for_deleter, TIMEOUT)
            .await
            .unwrap();
        deleter.logout().await.unwrap();
    });

    deleter_handle.await.unwrap();
    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
    let mut saw_delete = false;
    while tokio::time::Instant::now() < deadline && !saw_delete {
        if let Some(daaki_imap::TypedEvent::MailboxEvent(info)) = watcher
            .next_event(Duration::from_millis(500))
            .await
            .unwrap()
        {
            if info.name.as_str().eq_ignore_ascii_case(&mailbox_name)
                && info
                    .attributes
                    .iter()
                    .any(|attr| matches!(attr, MailboxAttribute::NonExistent))
            {
                saw_delete = true;
            }
        }
    }

    assert!(
        saw_delete,
        "delete must surface a MailboxEvent with the \\\\NonExistent attribute via next_event"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (personal (SubscriptionChange))` must enqueue subscription
/// changes that can be read via `next_event()`.
///
/// RFC 5465 Section 5.5: subscription changes are delivered as LIST responses
/// whose attributes reflect the new subscription state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_next_event_subscription_change_subscribe_sets_subscribed() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mailbox_name = unique_mailbox_name("notify-next-subscribe");
    watcher.create(&mailbox_name, TIMEOUT).await.unwrap();
    watcher.unsubscribe(&mailbox_name, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::SubscriptionChange],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set SubscriptionChange should succeed");
    let _ = watcher.drain_events().await;

    let mailbox_name_for_subscriber = mailbox_name.clone();
    let subscriber_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let subscriber = connect_dovecot().await;
        subscriber
            .subscribe(&mailbox_name_for_subscriber, TIMEOUT)
            .await
            .unwrap();
        subscriber.logout().await.unwrap();
    });

    subscriber_handle.await.unwrap();
    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
    let mut saw_subscribed = false;
    while tokio::time::Instant::now() < deadline && !saw_subscribed {
        if let Some(daaki_imap::TypedEvent::MailboxEvent(info)) = watcher
            .next_event(Duration::from_millis(500))
            .await
            .unwrap()
        {
            if info.name.as_str().eq_ignore_ascii_case(&mailbox_name)
                && info
                    .attributes
                    .iter()
                    .any(|attr| matches!(attr, MailboxAttribute::Subscribed))
            {
                saw_subscribed = true;
            }
        }
    }

    assert!(
        saw_subscribed,
        "subscribe must surface a MailboxEvent with the \\\\Subscribed attribute via next_event"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.delete(&mailbox_name, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY NONE` must stop subsequent mailbox-name notifications from being
/// queued for `next_event()`.
///
/// RFC 5465 Section 3: `NOTIFY NONE` cancels all active registrations, so
/// later mailbox changes must no longer be delivered.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_none_suppresses_followup_mailbox_create_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set MailboxName should succeed");
    let _ = watcher.drain_events().await;

    let baseline_mailbox = unique_mailbox_name("notify-none-baseline");
    let baseline_mailbox_for_creator = baseline_mailbox.clone();
    let baseline_creator = tokio::spawn(async move {
        let creator = connect_dovecot().await;
        creator
            .create(&baseline_mailbox_for_creator, TIMEOUT)
            .await
            .unwrap();
        creator.logout().await.unwrap();
    });

    baseline_creator.await.unwrap();
    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let baseline_deadline = tokio::time::Instant::now() + Duration::from_secs(15);
    let mut saw_baseline_create = false;
    while tokio::time::Instant::now() < baseline_deadline && !saw_baseline_create {
        if let Some(daaki_imap::TypedEvent::MailboxEvent(info)) =
            watcher.next_event(Duration::from_secs(1)).await.unwrap()
        {
            if info.name.as_str().eq_ignore_ascii_case(&baseline_mailbox) {
                assert!(
                    info.old_name.is_none(),
                    "create notifications must not carry OLDNAME: {info:?}"
                );
                assert!(
                    !info
                        .attributes
                        .iter()
                        .any(|attr| matches!(attr, MailboxAttribute::NonExistent)),
                    "create notifications must not mark the mailbox as \\\\NonExistent: {info:?}"
                );
                saw_baseline_create = true;
            }
        }
    }

    assert!(
        saw_baseline_create,
        "baseline create must surface a MailboxEvent before NOTIFY NONE disables delivery"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    let _ = watcher.drain_events().await;

    let mailbox_name = unique_mailbox_name("notify-none-create");
    let mailbox_name_for_creator = mailbox_name.clone();
    let creator_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let creator = connect_dovecot().await;
        creator
            .create(&mailbox_name_for_creator, TIMEOUT)
            .await
            .unwrap();
        creator.logout().await.unwrap();
    });

    creator_handle.await.unwrap();
    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
    let mut saw_forbidden_event = false;
    while tokio::time::Instant::now() < deadline {
        if let Some(daaki_imap::TypedEvent::MailboxEvent(info)) = watcher
            .next_event(Duration::from_millis(250))
            .await
            .unwrap()
        {
            if info.name.as_str().eq_ignore_ascii_case(&mailbox_name) {
                saw_forbidden_event = true;
                break;
            }
        }
    }

    assert!(
        !saw_forbidden_event,
        "NOTIFY NONE must suppress future MailboxEvent delivery for new mailbox changes"
    );

    let cleanup = connect_dovecot().await;
    let _ = cleanup.delete(&baseline_mailbox, TIMEOUT).await;
    let _ = cleanup.delete(&mailbox_name, TIMEOUT).await;
    cleanup.logout().await.unwrap();

    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (personal (MessageNew MessageExpunge))` followed by `idle()`
/// must surface a `MailboxStatus` event when a second session APPENDs a
/// message to a non-selected mailbox (RFC 5465 Section 5.2 — delivered as
/// STATUS for non-selected mailboxes).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_message_new_status_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }
    // Set up a fresh non-selected mailbox the watcher will monitor.
    // Unique-per-test name avoids collisions with parallel tests.
    let mbox = unique_mailbox_name("notify-status");
    watcher.create(&mbox, TIMEOUT).await.unwrap();

    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Mailboxes(vec![mbox.clone()]),
                    vec![
                        NotifyEvent::MessageNew {
                            fetch_attrs: vec![],
                        },
                        NotifyEvent::MessageExpunge,
                    ],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set MessageNew should succeed");
    let _ = watcher.drain_events().await;

    // Second session appends a message to the watched mailbox.
    let mbox_for_appender = mbox.clone();
    let appender_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        let appender = connect_dovecot().await;
        appender
            .append(&mbox_for_appender, &[], None, TEST_MESSAGE, TIMEOUT)
            .await
            .unwrap();
        tokio::time::sleep(Duration::from_millis(200)).await;
        appender.logout().await.unwrap();
    });

    let cancel = tokio_util::sync::CancellationToken::new();
    let event = watcher
        .idle(Duration::from_secs(15), cancel)
        .await
        .expect("idle should succeed");

    appender_handle.await.unwrap();

    match event {
        IdleEvent::MailboxStatus { mailbox, .. } => {
            assert!(
                mailbox.as_str().eq_ignore_ascii_case(&mbox),
                "expected MailboxStatus for {mbox}, got mailbox={mailbox}"
            );
        }
        other => panic!(
            "expected IdleEvent::MailboxStatus for non-selected MessageNew \
             notification, got: {other:?}"
        ),
    }

    // Cleanup: NOTIFY NONE first so DELETE doesn't trip a notification.
    watcher.notify_none(TIMEOUT).await.unwrap();
    let _ = watcher.delete(&mbox, TIMEOUT).await;
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (selected (MessageNew (UID FLAGS) MessageExpunge))`
/// followed by `idle()` on the selected mailbox must surface both
/// `EXISTS` and `FETCH` when another session APPENDs a message.
///
/// RFC 5465 Section 5.2: for the selected mailbox, `MessageNew`
/// notifications are delivered as mailbox-size updates plus a FETCH
/// carrying the requested attributes.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_selected_message_new_fetch_attrs() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mbox = unique_mailbox_name("notify-selected-new");
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher.select(&mbox, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Selected,
                    vec![
                        NotifyEvent::MessageNew {
                            fetch_attrs: vec!["UID".into(), "FLAGS".into()],
                        },
                        NotifyEvent::MessageExpunge,
                    ],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set selected MessageNew should succeed");
    let _ = watcher.drain_events().await;

    let mbox_for_appender = mbox.clone();
    let appender_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        let appender = connect_dovecot().await;
        appender
            .append(&mbox_for_appender, &[], None, TEST_MESSAGE, TIMEOUT)
            .await
            .unwrap();
        tokio::time::sleep(Duration::from_millis(200)).await;
        appender.logout().await.unwrap();
    });

    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    let mut saw_exists = false;
    let mut saw_fetch = false;
    let mut observed = Vec::new();
    while std::time::Instant::now() < deadline && !(saw_exists && saw_fetch) {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = watcher
            .idle(remaining.min(Duration::from_secs(5)), cancel)
            .await
            .expect("idle should succeed");
        observed.push(format!("{event:?}"));
        match event {
            IdleEvent::Exists(count) => {
                saw_exists = count >= 1;
            }
            IdleEvent::Fetch(fetch) => {
                if fetch.uid.is_some() && fetch.flags.is_some() {
                    saw_fetch = true;
                }
            }
            IdleEvent::ExtensionEvent(_)
            | IdleEvent::StatusUpdate { .. }
            | IdleEvent::Recent(_)
            | IdleEvent::Timeout => {}
            other => panic!("unexpected event during selected MessageNew watch: {other:?}"),
        }
    }

    appender_handle.await.unwrap();
    assert!(
        saw_exists,
        "selected MessageNew must surface an EXISTS event; observed={observed:?}"
    );
    assert!(
        saw_fetch,
        "selected MessageNew with fetch attrs must surface a FETCH event carrying UID and FLAGS; observed={observed:?}"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (selected (MessageNew MessageExpunge FlagChange))`
/// followed by `idle()` on the selected mailbox must surface a `FETCH`
/// update when another session changes message flags.
///
/// RFC 5465 Section 5.1: `FlagChange` notifications on the selected
/// mailbox are delivered as unsolicited `FETCH` responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_selected_flag_change_fetch() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mbox = unique_mailbox_name("notify-flag-change");
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher
        .append(&mbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    watcher.select(&mbox, TIMEOUT).await.unwrap();
    let initial = watcher
        .uid_fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uid = initial[0].uid.expect("message must have a UID");

    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Selected,
                    vec![
                        NotifyEvent::MessageNew {
                            fetch_attrs: vec![],
                        },
                        NotifyEvent::MessageExpunge,
                        NotifyEvent::FlagChange,
                    ],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set selected FlagChange should succeed");
    let _ = watcher.drain_events().await;

    let mbox_for_storer = mbox.clone();
    let storer_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        let storer = connect_dovecot().await;
        storer.select(&mbox_for_storer, TIMEOUT).await.unwrap();
        storer
            .uid_store(
                &SequenceSet::new(uid.to_string()).unwrap(),
                StoreOperation::Add,
                &[Flag::Seen],
                None,
                TIMEOUT,
            )
            .await
            .unwrap();
        tokio::time::sleep(Duration::from_millis(200)).await;
        storer.logout().await.unwrap();
    });

    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    let mut saw_flag_change = false;
    let mut observed = Vec::new();
    while std::time::Instant::now() < deadline && !saw_flag_change {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = watcher
            .idle(remaining.min(Duration::from_secs(5)), cancel)
            .await
            .expect("idle should succeed");
        observed.push(format!("{event:?}"));
        match event {
            IdleEvent::Fetch(fetch) => {
                let flags = fetch.flags.unwrap_or_default();
                if fetch.seq == 1 && flags.iter().any(|flag| matches!(flag, Flag::Seen)) {
                    saw_flag_change = true;
                }
            }
            IdleEvent::ExtensionEvent(_)
            | IdleEvent::StatusUpdate { .. }
            | IdleEvent::Recent(_)
            | IdleEvent::Timeout => {}
            other => panic!("unexpected event during FlagChange watch: {other:?}"),
        }
    }

    storer_handle.await.unwrap();
    assert!(
        saw_flag_change,
        "selected FlagChange must surface a FETCH update with the changed flags; observed={observed:?}"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (selected-delayed (MessageNew MessageExpunge))` and
/// `NOTIFY NONE` must both succeed on a selected mailbox.
///
/// RFC 5465 Section 6.2 defines the `selected-delayed` mailbox filter,
/// which delays expunge delivery until a command that permits it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_set_selected_delayed_then_none() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let conn = connect_dovecot().await;
    if !require_dovecot_notify(&conn).await {
        let _ = conn.logout().await;
        return;
    }

    let mbox = unique_mailbox_name("notify-selected-delayed");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.notify_set(
        NotifySetParams::new(
            vec![NotifyEventGroup::new(
                MailboxFilter::SelectedDelayed,
                vec![
                    NotifyEvent::MessageNew {
                        fetch_attrs: vec![],
                    },
                    NotifyEvent::MessageExpunge,
                ],
            )],
            false,
        ),
        TIMEOUT,
    )
    .await
    .expect("notify_set selected-delayed should succeed");

    conn.notify_none(TIMEOUT).await.unwrap();
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `NOTIFY SET (personal (SubscriptionChange))` followed by `idle()`
/// must surface a `MailboxEvent` with `\\Subscribed` after another
/// session subscribes the mailbox.
///
/// RFC 5465 Section 5.5: subscription changes are delivered as unsolicited
/// LIST responses whose attributes reflect the new subscription state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_subscription_change_subscribe_sets_subscribed() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_mailbox_name("notify-user");
    let watcher = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mbox = unique_mailbox_name("notify-subscribe");
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher.unsubscribe(&mbox, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::SubscriptionChange],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set SubscriptionChange should succeed");
    let _ = watcher.drain_events().await;

    let user_for_subscriber = user.clone();
    let mbox_for_subscriber = mbox.clone();
    let subscriber_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        let subscriber = connect_dovecot_as(&user_for_subscriber).await;
        subscriber
            .subscribe(&mbox_for_subscriber, TIMEOUT)
            .await
            .unwrap();
        tokio::time::sleep(Duration::from_millis(200)).await;
        subscriber.logout().await.unwrap();
    });

    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    let mut saw_subscribed = false;
    let mut observed = Vec::new();
    while std::time::Instant::now() < deadline && !saw_subscribed {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = watcher
            .idle(remaining.min(Duration::from_secs(5)), cancel)
            .await
            .expect("idle should succeed");
        observed.push(format!("{event:?}"));
        match event {
            IdleEvent::MailboxEvent(info)
                if info.name.as_str().eq_ignore_ascii_case(&mbox)
                    && info
                        .attributes
                        .iter()
                        .any(|attr| matches!(attr, MailboxAttribute::Subscribed)) =>
            {
                saw_subscribed = true;
            }
            IdleEvent::MailboxEvent(_)
            | IdleEvent::ExtensionEvent(_)
            | IdleEvent::StatusUpdate { .. }
            | IdleEvent::Timeout => {}
            other => panic!("unexpected event during subscribe watch: {other:?}"),
        }
    }

    subscriber_handle.await.unwrap();
    assert!(
        saw_subscribed,
        "subscription change must surface a MailboxEvent with the \\\\Subscribed attribute; observed={observed:?}"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `NOTIFY SET (personal (SubscriptionChange))` followed by `idle()`
/// must surface a `MailboxEvent` without `\\Subscribed` after another
/// session unsubscribes the mailbox.
///
/// RFC 5465 Section 5.5: subscription changes are delivered as unsolicited
/// LIST responses whose attributes reflect the new subscription state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_idle_subscription_change_unsubscribe_clears_subscribed() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_mailbox_name("notify-unsubscribe-user");
    let watcher = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mbox = unique_mailbox_name("notify-unsubscribe");
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher.subscribe(&mbox, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::SubscriptionChange],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .expect("notify_set SubscriptionChange should succeed");
    let _ = watcher.drain_events().await;

    let mbox_for_unsubscriber = mbox.clone();
    let user_for_unsubscriber = user.clone();
    let unsubscriber_handle = tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(500)).await;
        let unsubscriber = connect_dovecot_as(&user_for_unsubscriber).await;
        unsubscriber
            .unsubscribe(&mbox_for_unsubscriber, TIMEOUT)
            .await
            .unwrap();
        tokio::time::sleep(Duration::from_millis(200)).await;
        unsubscriber.logout().await.unwrap();
    });

    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    let mut saw_unsubscribed = false;
    let mut observed = Vec::new();
    while std::time::Instant::now() < deadline && !saw_unsubscribed {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        let cancel = tokio_util::sync::CancellationToken::new();
        let event = watcher
            .idle(remaining.min(Duration::from_secs(5)), cancel)
            .await
            .expect("idle should succeed");
        observed.push(format!("{event:?}"));
        match event {
            IdleEvent::MailboxEvent(info)
                if info.name.as_str().eq_ignore_ascii_case(&mbox)
                    && !info
                        .attributes
                        .iter()
                        .any(|attr| matches!(attr, MailboxAttribute::Subscribed)) =>
            {
                saw_unsubscribed = true;
            }
            IdleEvent::MailboxEvent(_)
            | IdleEvent::ExtensionEvent(_)
            | IdleEvent::StatusUpdate { .. }
            | IdleEvent::Timeout => {}
            other => panic!("unexpected event during unsubscribe watch: {other:?}"),
        }
    }

    unsubscriber_handle.await.unwrap();
    assert!(
        saw_unsubscribed,
        "subscription change must surface a MailboxEvent without the \\\\Subscribed attribute after unsubscribe; observed={observed:?}"
    );

    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// Extension events (`Other(...)`) on `selected`/`selected-delayed` filters
/// must be rejected *client-side* before reaching the wire.
///
/// RFC 5465 Section 8 defines `event-ext` as a separate ABNF production from
/// `message-event`. Section 6.1 restricts selected/selected-delayed filters
/// to message events only. The encoder enforces this constraint, so the
/// command never reaches the server — the error is `Error::Protocol`, not
/// `Error::No [BADEVENT]`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_notify_set_extension_event_rejected_client_side() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let conn = connect_dovecot().await;
    if !require_dovecot_notify(&conn).await {
        conn.logout().await.unwrap();
        return;
    }
    conn.select("INBOX", TIMEOUT).await.unwrap();

    let result = conn
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Selected,
                    vec![NotifyEvent::Other("BogusEvent".into())],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await;

    // The encoder rejects extension events on selected filters per
    // RFC 5465 Section 6.1 / Section 8 ABNF. This is a client-side
    // Protocol error, not a server NO.
    assert!(
        matches!(result, Err(daaki_imap::Error::Protocol(_))),
        "extension event on selected filter must be rejected by the encoder, got: {result:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Pipelining (RFC 3501 §5.5)
// ==========================================================================

/// Pipeline two UID FETCH ENVELOPE commands for disjoint UID ranges and verify
/// each result contains exactly the expected messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_fetch_disjoint_uids() {
    let conn = connect_dovecot().await;
    let mbox = &format!("test-pipeline-fetch-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Append 3 messages with distinct subjects for attribution verification.
    for i in 1..=3u8 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Pipeline test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <pipeline-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Body {i}\r\n"
        );
        conn.append(mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    conn.select(mbox, TIMEOUT).await.unwrap();

    // Discover UIDs via UID SEARCH ALL.
    let search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(search.ids.len(), 3, "expected 3 messages after append");
    let mut uids = search.ids.clone();
    uids.sort_unstable();

    // Pipeline: first command fetches UID[0], second fetches UID[1..=2].
    let set1 = SequenceSet::new(uids[0].to_string()).unwrap();
    let set2 = SequenceSet::new(format!("{}:{}", uids[1], uids[2])).unwrap();

    let (result1, result2) = conn
        .pipeline()
        .uid_fetch(set1, "(UID ENVELOPE)".to_string(), None, false)
        .uid_fetch(set2, "(UID ENVELOPE)".to_string(), None, false)
        .execute()
        .await
        .unwrap();

    let (fetches1, _vanished1) = result1.unwrap();
    let (fetches2, _vanished2) = result2.unwrap();

    assert_eq!(fetches1.len(), 1, "first fetch should return 1 message");
    assert_eq!(fetches2.len(), 2, "second fetch should return 2 messages");

    // Verify UID attribution.
    assert_eq!(fetches1[0].uid.unwrap(), uids[0]);
    assert!(fetches2.iter().any(|f| f.uid.unwrap() == uids[1]));
    assert!(fetches2.iter().any(|f| f.uid.unwrap() == uids[2]));

    // Verify envelope subjects to confirm correct response routing.
    let subj1 = fetches1[0]
        .envelope
        .as_ref()
        .expect("envelope should be present")
        .subject
        .as_deref()
        .expect("subject should be present");
    assert!(
        subj1.contains("Pipeline test 1"),
        "first fetch subject mismatch: {subj1}"
    );

    // Verify subjects in second fetch for correct response routing.
    let subjects2: Vec<&str> = fetches2
        .iter()
        .map(|f| {
            f.envelope
                .as_ref()
                .expect("envelope should be present")
                .subject
                .as_deref()
                .expect("subject should be present")
        })
        .collect();
    assert!(
        subjects2.iter().any(|s| s.contains("Pipeline test 2")),
        "second fetch missing 'Pipeline test 2': {subjects2:?}"
    );
    assert!(
        subjects2.iter().any(|s| s.contains("Pipeline test 3")),
        "second fetch missing 'Pipeline test 3': {subjects2:?}"
    );

    // Cleanup: close (deselect) then delete the test mailbox.
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// Pipeline two STATUS commands on different mailboxes and verify each result
/// reports the correct message count.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_status_two_mailboxes() {
    let conn = connect_dovecot().await;

    let mbox_a = &format!("test-pipeline-status-a-{}", std::process::id());
    let mbox_b = &format!("test-pipeline-status-b-{}", std::process::id());
    conn.create(mbox_a, TIMEOUT).await.unwrap();
    conn.create(mbox_b, TIMEOUT).await.unwrap();

    // Append 2 messages to A, 1 to B.
    conn.append(mbox_a, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox_a, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    conn.append(mbox_b, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let name_a = MailboxName::new(mbox_a).unwrap();
    let name_b = MailboxName::new(mbox_b).unwrap();

    let (result_a, result_b) = conn
        .pipeline()
        .status(name_a, "(MESSAGES)".to_string())
        .status(name_b, "(MESSAGES)".to_string())
        .execute()
        .await
        .unwrap();

    let status_a = result_a.unwrap();
    let status_b = result_b.unwrap();

    let a_messages = status_a.items.iter().find_map(|i| match i {
        StatusItem::Messages(n) => Some(*n),
        _ => None,
    });
    let b_messages = status_b.items.iter().find_map(|i| match i {
        StatusItem::Messages(n) => Some(*n),
        _ => None,
    });

    assert_eq!(a_messages, Some(2), "mailbox A should have 2 messages");
    assert_eq!(b_messages, Some(1), "mailbox B should have 1 message");

    // Cleanup.
    conn.delete(mbox_a, TIMEOUT).await.unwrap();
    conn.delete(mbox_b, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// Pipeline where the second command fails (STATUS on a nonexistent mailbox).
/// The first command must still succeed — per-command errors do not abort the
/// pipeline.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_partial_failure() {
    let conn = connect_dovecot().await;

    let good_mbox = MailboxName::new("INBOX").unwrap();
    let bad_mbox =
        MailboxName::new(format!("nonexistent-pipeline-{}", std::process::id())).unwrap();

    let (good_result, bad_result) = conn
        .pipeline()
        .status(good_mbox, "(MESSAGES)".to_string())
        .status(bad_mbox, "(MESSAGES)".to_string())
        .execute()
        .await
        .unwrap();

    // First command should succeed with a valid MESSAGES item.
    let good_status = good_result.expect("STATUS INBOX should succeed");
    let inbox_messages = good_status.items.iter().find_map(|i| match i {
        StatusItem::Messages(n) => Some(*n),
        _ => None,
    });
    assert!(
        inbox_messages.is_some(),
        "STATUS INBOX should include MESSAGES: {:?}",
        good_status.items
    );

    // Second command should fail with NO (nonexistent mailbox).
    assert!(
        matches!(bad_result, Err(daaki_imap::Error::No { .. })),
        "STATUS on nonexistent mailbox should produce Error::No, got: {bad_result:?}"
    );

    conn.logout().await.unwrap();
}

// ==========================================================================
// Streaming FETCH (RFC 3501 Section 6.4.5)
// ==========================================================================

/// `uid_fetch_streaming` must deliver each FETCH response via the provided
/// mpsc channel rather than collecting them in memory.
///
/// RFC 3501 Section 6.4.5: "The FETCH command retrieves data associated
/// with a message." The streaming variant pushes results one-by-one.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_streaming() {
    let conn = connect_dovecot().await;

    let mbox = format!("test-streaming-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Append two messages so the stream produces multiple items.
    conn.append(&mbox, &[Flag::Seen], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    let msg2 = b"From: other@example.com\r\n\
                  To: recipient@example.com\r\n\
                  Subject: streaming test 2\r\n\
                  Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
                  Message-ID: <stream-2@daaki>\r\n\
                  \r\n\
                  Second message.\r\n";
    conn.append(&mbox, &[], None, msg2, TIMEOUT).await.unwrap();

    // Re-select to see the appended messages.
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    let seq = SequenceSet::new("1:*").unwrap();
    conn.uid_fetch_streaming(&seq, &[FetchAttr::Uid, FetchAttr::Flags], tx, TIMEOUT)
        .await
        .unwrap();

    let mut received = Vec::new();
    while let Some(item) = rx.recv().await {
        received.push(item.unwrap());
    }

    assert_eq!(
        received.len(),
        2,
        "streaming fetch should yield 2 responses, got {}",
        received.len()
    );
    // Each response must include a UID.
    for resp in &received {
        assert!(resp.uid.is_some(), "each streamed response must have a UID");
    }

    // Cleanup.
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `fetch_changed_since` must fail fast when the server does not advertise
/// CONDSTORE.
///
/// RFC 7162 Section 3.1.4 defines `CHANGEDSINCE` as a CONDSTORE extension.
/// A client must not treat it as available against a server that omitted the
/// `CONDSTORE` capability.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_fetch_changed_since_requires_condstore_capability() {
    let conn = connect_greenmail().await;

    let caps = conn.capabilities();
    if caps.iter().any(|cap| matches!(cap, Capability::Condstore)) {
        eprintln!("skipping: GreenMail advertises CONDSTORE in this environment");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = format!("test-greenmail-fetch-changed-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let result = conn
        .fetch_changed_since(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            1,
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::MissingCapability(ref cap)) if cap == "CONDSTORE"),
        "expected Error::MissingCapability(\"CONDSTORE\"), got {result:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `uid_fetch_changed_since` must fail fast when the server does not
/// advertise CONDSTORE.
///
/// RFC 7162 Section 3.1.4 defines `CHANGEDSINCE` as a CONDSTORE extension for
/// both `FETCH` and `UID FETCH`; the capability requirement is the same for
/// the UID form.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_uid_fetch_changed_since_requires_condstore_capability() {
    let conn = connect_greenmail().await;

    let caps = conn.capabilities();
    if caps.iter().any(|cap| matches!(cap, Capability::Condstore)) {
        eprintln!("skipping: GreenMail advertises CONDSTORE in this environment");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = format!("test-greenmail-uid-fetch-changed-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let result = conn
        .uid_fetch_changed_since(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            1,
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::MissingCapability(ref cap)) if cap == "CONDSTORE"),
        "expected Error::MissingCapability(\"CONDSTORE\"), got {result:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `authenticate_xoauth2` must reject use when the server does not advertise
/// `AUTH=XOAUTH2`.
///
/// RFC 3501 Section 6.2.2 requires clients to use only advertised
/// authentication mechanisms. The public API should therefore surface a
/// capability error before attempting XOAUTH2 against Dovecot here.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_xoauth2_requires_advertised_capability() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let caps = conn.capabilities();
    if caps
        .iter()
        .any(|cap| matches!(cap, Capability::Auth(mech) if mech.eq_ignore_ascii_case("XOAUTH2")))
    {
        eprintln!("skipping: Dovecot advertises AUTH=XOAUTH2 in this environment");
        conn.logout().await.unwrap();
        return;
    }

    let result = conn
        .authenticate_xoauth2(dovecot::USER, "invalid-token", TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::MissingCapability(ref cap)) if cap == "AUTH=XOAUTH2"),
        "expected Error::MissingCapability(\"AUTH=XOAUTH2\"), got {result:?}"
    );

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// CREATE with MAILBOXID (RFC 8474 Section 4.1)
// ==========================================================================

/// `create_with_mailbox_id` must return a MAILBOXID when the server
/// supports the OBJECTID extension.
///
/// RFC 8474 Section 4.1: the CREATE response MAY include a MAILBOXID
/// response code. If OBJECTID is not advertised, the method should
/// still succeed with `None`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_with_mailbox_id() {
    let conn = connect_dovecot().await;

    let mbox = format!("test-mailboxid-{}", std::process::id());
    let result = conn.create_with_mailbox_id(&mbox, TIMEOUT).await;

    match result {
        Ok(maybe_id) => {
            // If the server supports OBJECTID, we get a Some(id).
            // If not, we get None — both are valid.
            if let Some(ref id) = maybe_id {
                assert!(!id.is_empty(), "MAILBOXID must not be empty if present");
            }
            // Cleanup: delete the created mailbox.
            conn.delete(&mbox, TIMEOUT).await.unwrap();
        }
        Err(daaki_imap::Error::MissingCapability(_)) => {
            eprintln!("skipping: server does not support OBJECTID / CREATE MAILBOXID");
        }
        Err(e) => panic!("unexpected error: {e:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// FETCH nonexistent UID (RFC 3501 Section 6.4.8)
// ==========================================================================

/// Fetching a UID that does not exist must return an empty result set,
/// not an error.
///
/// RFC 3501 Section 6.4.8 (UID command): "A non-existent unique
/// identifier is ignored without any error message generated."
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_nonexistent_uid_returns_empty() {
    let conn = connect_dovecot().await;

    let mbox = format!("test-nofetch-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // UID 999999 should not exist in an empty mailbox.
    let seq = SequenceSet::new("999999").unwrap();
    let results = conn
        .uid_fetch(&seq, &[FetchAttr::Uid, FetchAttr::Flags], TIMEOUT)
        .await
        .unwrap();

    assert!(
        results.is_empty(),
        "fetching a nonexistent UID must return an empty result, got {} items",
        results.len()
    );

    // Cleanup.
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// drain_events — empty on fresh connection (RFC 3501 Section 5.3)
// ==========================================================================

/// `drain_events()` on a freshly connected, idle connection must return
/// an empty vector — there should be no pending unsolicited responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_initially_empty() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    let events = conn.drain_events().await;

    // On a fresh, quiet connection there should be no mailbox-modifying
    // events. Capability-related or informational events are tolerable.
    for ev in &events {
        assert!(
            !matches!(
                ev,
                daaki_imap::TypedEvent::Exists(_) | daaki_imap::TypedEvent::Expunge(_)
            ),
            "unexpected mailbox-modifying event on fresh connection: {ev:?}"
        );
    }

    conn.logout().await.unwrap();
}

/// `drain_events()` must surface a queued EXISTS update after another session
/// appends to the currently selected mailbox.
///
/// RFC 3501 Section 7.3.1: the server sends an untagged EXISTS response when
/// the mailbox size changes. RFC 3501 Section 6.1.2: `NOOP` lets the server
/// send pending mailbox updates immediately.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_receives_exists_after_external_append() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    let appender = connect_dovecot().await;

    let mailbox = unique_mailbox_name("drain-exists");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    watcher.select(&mailbox, TIMEOUT).await.unwrap();
    let _ = watcher.drain_events().await;

    appender
        .append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();

    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| matches!(event, TypedEvent::Exists(1)),
    )
    .await;

    assert!(
        events
            .iter()
            .any(|event| matches!(event, TypedEvent::Exists(1))),
        "drain_events must surface EXISTS after external APPEND; events={events:?}"
    );

    appender.logout().await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `drain_events()` must surface a queued FETCH update after another session
/// changes flags in the currently selected mailbox.
///
/// RFC 3501 Section 7.4.2: servers send untagged FETCH responses to report
/// dynamic message attribute changes such as flag updates. RFC 3501 Section
/// 6.1.2: `NOOP` allows delivery of pending mailbox updates.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_receives_fetch_update_after_external_store() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    let modifier = connect_dovecot().await;

    let mailbox = unique_mailbox_name("drain-fetch");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    watcher.select(&mailbox, TIMEOUT).await.unwrap();
    watcher
        .append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    modifier.select(&mailbox, TIMEOUT).await.unwrap();
    let uid = modifier
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap()
        .pop()
        .and_then(|fetch| fetch.uid)
        .expect("expected UID for appended message");
    modifier
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();

    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| {
            matches!(
                event,
                TypedEvent::FetchUpdate(fetch)
                    if fetch.seq == 1
                        && fetch
                            .flags
                            .as_ref()
                            .is_some_and(|flags| flags.contains(&Flag::Flagged))
            )
        },
    )
    .await;

    let fetch = events
        .iter()
        .find_map(|event| match event {
            TypedEvent::FetchUpdate(fetch)
                if fetch.seq == 1
                    && fetch
                        .flags
                        .as_ref()
                        .is_some_and(|flags| flags.contains(&Flag::Flagged)) =>
            {
                Some(fetch)
            }
            _ => None,
        })
        .expect("drain_events must surface a FETCH update after external STORE");
    assert_eq!(fetch.seq, 1, "FETCH update must refer to sequence number 1");

    modifier.close(TIMEOUT).await.unwrap();
    modifier.logout().await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `drain_events()` must surface a queued EXPUNGE update after another session
/// expunges a message from the currently selected mailbox.
///
/// RFC 3501 Section 7.4.1: the server sends an untagged EXPUNGE response when
/// a message is permanently removed. RFC 3501 Section 6.1.2: `NOOP` permits
/// delivery of pending mailbox updates on an otherwise quiet connection.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_receives_expunge_after_external_expunge() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let watcher = connect_dovecot().await;
    let expunger = connect_dovecot().await;

    let mailbox = unique_mailbox_name("drain-expunge");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    watcher.select(&mailbox, TIMEOUT).await.unwrap();
    watcher
        .append(&mailbox, &[], None, TEST_MESSAGE, TIMEOUT)
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    expunger.select(&mailbox, TIMEOUT).await.unwrap();
    let uid = expunger
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap()
        .pop()
        .and_then(|fetch| fetch.uid)
        .expect("expected UID for message to expunge");
    expunger
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Add,
            &[Flag::Deleted],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();
    expunger.expunge(TIMEOUT).await.unwrap();

    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| matches!(event, TypedEvent::Expunge(1)),
    )
    .await;

    assert!(
        events
            .iter()
            .any(|event| matches!(event, TypedEvent::Expunge(1))),
        "drain_events must surface EXPUNGE after external expunge; events={events:?}"
    );

    expunger.close(TIMEOUT).await.unwrap();
    expunger.logout().await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `drain_events()` must surface a queued NOTIFY mailbox creation event.
///
/// RFC 5465 Section 5.4: mailbox creation notifications are delivered as
/// unsolicited LIST responses. The typed event queue must surface that LIST as
/// `TypedEvent::MailboxEvent`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_receives_notify_mailbox_create_event() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let user = unique_mailbox_name("drain-user");
    let watcher = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    let mailbox = unique_mailbox_name("drain-notify-create");
    let creator = connect_dovecot_as(&user).await;
    creator.create(&mailbox, TIMEOUT).await.unwrap();

    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| {
            matches!(
                event,
                TypedEvent::MailboxEvent(info)
                    if info.name.as_str().eq_ignore_ascii_case(&mailbox)
            )
        },
    )
    .await;

    let info = events
        .iter()
        .find_map(|event| match event {
            TypedEvent::MailboxEvent(info) if info.name.as_str().eq_ignore_ascii_case(&mailbox) => {
                Some(info)
            }
            _ => None,
        })
        .expect("drain_events must surface a MailboxEvent for the created mailbox");
    assert!(
        info.old_name.is_none(),
        "create notifications must not carry OLDNAME: {info:?}"
    );
    assert!(
        !info
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::NonExistent)),
        "create notifications must not mark the mailbox as \\\\NonExistent: {info:?}"
    );

    creator.logout().await.unwrap();
    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `drain_events()` must surface a queued NOTIFY mailbox deletion event with
/// the `\\NonExistent` attribute.
///
/// RFC 5465 Section 5.4: mailbox deletion notifications are delivered as LIST
/// responses. RFC 9051 Section 7.2.2: deleted mailbox notifications carry the
/// `\\NonExistent` mailbox attribute.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_receives_notify_mailbox_delete_event() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let user = unique_mailbox_name("drain-user");
    let watcher = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    let mailbox = unique_mailbox_name("drain-notify-delete");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    let deleter = connect_dovecot_as(&user).await;
    deleter.delete(&mailbox, TIMEOUT).await.unwrap();

    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| {
            matches!(
                event,
                TypedEvent::MailboxEvent(info)
                    if info.name.as_str().eq_ignore_ascii_case(&mailbox)
                        && info
                            .attributes
                            .iter()
                            .any(|attr| matches!(attr, MailboxAttribute::NonExistent))
            )
        },
    )
    .await;

    let info = events
        .iter()
        .find_map(|event| match event {
            TypedEvent::MailboxEvent(info)
                if info.name.as_str().eq_ignore_ascii_case(&mailbox)
                    && info
                        .attributes
                        .iter()
                        .any(|attr| matches!(attr, MailboxAttribute::NonExistent)) =>
            {
                Some(info)
            }
            _ => None,
        })
        .expect("drain_events must surface a deletion MailboxEvent with \\\\NonExistent");
    assert!(
        info.old_name.is_none(),
        "delete notifications must not carry OLDNAME: {info:?}"
    );

    deleter.logout().await.unwrap();
    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `drain_events()` must consume queued events so an immediate second call is
/// empty when no new server data has arrived.
///
/// RFC 5465 Section 5.4: mailbox creation notifications are delivered as LIST
/// responses. The `drain_events()` API contract requires each pending event to
/// be returned exactly once.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_consumes_queue_after_notify_event() {
    let _guard = dovecot_drain_events_test_lock().lock().await;
    let user = unique_mailbox_name("drain-user");
    let watcher = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        let _ = watcher.logout().await;
        return;
    }

    watcher
        .notify_set(
            NotifySetParams::new(
                vec![NotifyEventGroup::new(
                    MailboxFilter::Personal,
                    vec![NotifyEvent::MailboxName],
                )],
                false,
            ),
            TIMEOUT,
        )
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    let mailbox = unique_mailbox_name("drain-notify-consume");
    let creator = connect_dovecot_as(&user).await;
    creator.create(&mailbox, TIMEOUT).await.unwrap();

    let first_batch = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| {
            matches!(
                event,
                TypedEvent::MailboxEvent(info)
                    if info.name.as_str().eq_ignore_ascii_case(&mailbox)
            )
        },
    )
    .await;
    assert!(
        first_batch.iter().any(|event| matches!(
            event,
            TypedEvent::MailboxEvent(info) if info.name.as_str().eq_ignore_ascii_case(&mailbox)
        )),
        "first drain_events call must return the queued mailbox creation event; events={first_batch:?}"
    );

    let second_batch = watcher.drain_events().await;
    assert!(
        second_batch.is_empty(),
        "after drain_events consumes queued events, immediate second call must be empty; events={second_batch:?}"
    );

    creator.logout().await.unwrap();
    watcher.notify_none(TIMEOUT).await.unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

// ==========================================================================
// LIST-EXTENDED with CHILDREN return option (RFC 5258 Section 3.4)
// ==========================================================================

/// `list_extended` with the CHILDREN return option must annotate each
/// mailbox with `\HasChildren` or `\HasNoChildren`.
///
/// RFC 5258 Section 3.4: "The CHILDREN return option causes the server
/// to return information about whether or not each mailbox has child
/// mailboxes."
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_children_info() {
    let conn = connect_dovecot().await;

    let result = conn
        .list_extended("", &["*"], &[], &["CHILDREN"], TIMEOUT)
        .await;

    match result {
        Ok(mailboxes) => {
            assert!(!mailboxes.is_empty(), "LIST must return at least INBOX");
            // Every mailbox should have either HasChildren or HasNoChildren.
            for mbox in &mailboxes {
                let has_children_attr = mbox.attributes.iter().any(|a| {
                    matches!(
                        a,
                        MailboxAttribute::HasChildren | MailboxAttribute::HasNoChildren
                    )
                });
                assert!(
                    has_children_attr,
                    "with CHILDREN return option, mailbox {:?} must have \\HasChildren or \\HasNoChildren attribute, got {:?}",
                    mbox.name, mbox.attributes
                );
            }
        }
        Err(daaki_imap::Error::MissingCapability(_) | daaki_imap::Error::No { .. }) => {
            eprintln!("skipping: server does not support LIST-EXTENDED CHILDREN");
        }
        Err(e) => panic!("unexpected error: {e:?}"),
    }

    conn.logout().await.unwrap();
}

// ==========================================================================
// Multiple APPEND then range FETCH (RFC 3501 Sections 6.3.11, 6.4.5)
// ==========================================================================

/// Append several messages, then fetch them all with multiple attributes
/// in a single FETCH command to verify the server correctly returns all
/// requested data items for each message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multiple_appends_range_fetch() {
    let conn = connect_dovecot().await;

    let mbox = format!("test-range-fetch-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();

    // Append 3 messages with distinct subjects.
    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Range fetch test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <range-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Body of message {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Fetch all messages with multiple attributes.
    let seq = SequenceSet::new("1:*").unwrap();
    let results = conn
        .uid_fetch(
            &seq,
            &[
                FetchAttr::Uid,
                FetchAttr::Flags,
                FetchAttr::Envelope,
                FetchAttr::Rfc822Size,
                FetchAttr::InternalDate,
            ],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        results.len(),
        3,
        "must fetch exactly 3 messages, got {}",
        results.len()
    );

    for (i, resp) in results.iter().enumerate() {
        assert!(resp.uid.is_some(), "message {} must have a UID", i + 1);
        assert!(
            resp.envelope.is_some(),
            "message {} must have an envelope",
            i + 1
        );
        assert!(
            resp.rfc822_size.is_some(),
            "message {} must have an RFC822.SIZE",
            i + 1
        );
        assert!(
            resp.internal_date.is_some(),
            "message {} must have an INTERNALDATE",
            i + 1
        );

        // Verify the envelope subject matches what we appended.
        if let Some(ref envelope) = resp.envelope {
            let expected_subject = format!("Range fetch test {}", i + 1);
            assert_eq!(
                envelope.subject.as_deref(),
                Some(expected_subject.as_str()),
                "message {} subject mismatch",
                i + 1
            );
        }
    }

    // Cleanup.
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// STARTTLS must refetch capabilities on the protected channel, and the
/// refreshed capability set must no longer advertise `STARTTLS`.
///
/// RFC 3501 Section 6.2.1: after a successful STARTTLS command, the client
/// MUST discard cached capabilities and SHOULD issue CAPABILITY again. A
/// server on an already-encrypted channel must not continue advertising
/// `STARTTLS`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_starttls_refetches_capabilities_without_starttls() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    let caps_before = conn.capabilities();
    assert!(
        caps_before
            .iter()
            .any(|cap| matches!(cap, Capability::StartTls)),
        "plaintext capability list must advertise STARTTLS before upgrade: {caps_before:?}"
    );

    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    let caps_after = conn.capabilities();
    assert!(
        !caps_after
            .iter()
            .any(|cap| matches!(cap, Capability::StartTls)),
        "post-STARTTLS capabilities must be refreshed and must not advertise STARTTLS: {caps_after:?}"
    );

    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    conn.logout().await.unwrap();
}

// ==========================================================================
// Helpers
// ==========================================================================

/// Connect and authenticate against Dovecot (STARTTLS required for auth).
async fn connect_dovecot() -> DovecotConnection {
    init_tracing();
    let permit = Arc::clone(dovecot_connection_limiter())
        .acquire_owned()
        .await
        .unwrap();
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    conn.login(dovecot::USER, dovecot::PASS, TIMEOUT)
        .await
        .unwrap();
    DovecotConnection {
        conn,
        _permit: permit,
    }
}

/// Connect and authenticate against Dovecot as the provided user.
async fn connect_dovecot_as(user: &str) -> DovecotConnection {
    init_tracing();
    let permit = Arc::clone(dovecot_connection_limiter())
        .acquire_owned()
        .await
        .unwrap();
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        dovecot::HOST,
        dovecot::IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    conn.login(user, dovecot::PASS, TIMEOUT).await.unwrap();
    DovecotConnection {
        conn,
        _permit: permit,
    }
}

/// Connect and authenticate against GreenMail (plaintext).
async fn connect_greenmail() -> ImapConnection {
    init_tracing();
    let conn = ImapConnection::connect(
        greenmail::HOST,
        greenmail::IMAP_PORT,
        TlsMode::None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.login(greenmail::USER, greenmail::PASS, TIMEOUT)
        .await
        .unwrap();
    conn
}

/// TLS connection establishment must fail closed when certificate validation fails.
///
/// RFC 3501 Sections 6.1.1 and 6.2.1 require the protected channel to be
/// negotiated before the session proceeds.
fn assert_tls_validation_error<T>(result: Result<T, daaki_imap::Error>) {
    match result {
        Err(daaki_imap::Error::Io(err)) => {
            let text = err.to_string().to_ascii_lowercase();
            assert!(
                text.contains("certificate")
                    || text.contains("unknown issuer")
                    || text.contains("self signed")
                    || text.contains("invalid peer certificate")
                    || text.contains("not valid for name")
                    || text.contains("cert"),
                "expected TLS certificate-validation failure, got I/O error: {err}"
            );
        }
        Err(other) => panic!("expected TLS certificate-validation error, got {other:?}"),
        Ok(_) => panic!("expected TLS certificate-validation error, got success"),
    }
}

/// Build a TLS config that accepts any certificate (for Docker self-signed certs).
fn build_danger_tls_config() -> Arc<rustls::ClientConfig> {
    mod danger {
        #[derive(Debug)]
        pub struct AcceptAll;

        impl rustls::client::danger::ServerCertVerifier for AcceptAll {
            fn verify_server_cert(
                &self,
                _end_entity: &rustls_pki_types::CertificateDer<'_>,
                _intermediates: &[rustls_pki_types::CertificateDer<'_>],
                _server_name: &rustls_pki_types::ServerName<'_>,
                _ocsp_response: &[u8],
                _now: rustls_pki_types::UnixTime,
            ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
                Ok(rustls::client::danger::ServerCertVerified::assertion())
            }

            fn verify_tls12_signature(
                &self,
                _message: &[u8],
                _cert: &rustls_pki_types::CertificateDer<'_>,
                _dss: &rustls::DigitallySignedStruct,
            ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
            {
                Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
            }

            fn verify_tls13_signature(
                &self,
                _message: &[u8],
                _cert: &rustls_pki_types::CertificateDer<'_>,
                _dss: &rustls::DigitallySignedStruct,
            ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
            {
                Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
            }

            fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
                vec![
                    rustls::SignatureScheme::RSA_PKCS1_SHA256,
                    rustls::SignatureScheme::RSA_PKCS1_SHA384,
                    rustls::SignatureScheme::RSA_PKCS1_SHA512,
                    rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
                    rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
                    rustls::SignatureScheme::RSA_PSS_SHA256,
                    rustls::SignatureScheme::RSA_PSS_SHA384,
                    rustls::SignatureScheme::RSA_PSS_SHA512,
                    rustls::SignatureScheme::ED25519,
                ]
            }
        }
    }

    let _ = rustls::crypto::ring::default_provider().install_default();

    let config = rustls::ClientConfig::builder()
        .dangerous()
        .with_custom_certificate_verifier(Arc::new(danger::AcceptAll))
        .with_no_client_auth();
    Arc::new(config)
}
