//! Extended Dovecot integration tests — edge cases, missing method coverage,
//! and stress scenarios.
//!
//! These tests complement `integration.rs` by covering API surface gaps and
//! protocol edge cases specifically against Dovecot.
//!
//! # Setup
//!
//! ```sh
//! docker compose up -d
//! cargo test -p daaki-imap -- --ignored
//! docker compose down
//! ```

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

use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::sync::{Arc, Once, OnceLock};
use std::time::Duration;

use daaki_imap::types::{
    AclEntry, AppendMessage, Capability, FetchAttr, Flag, ListRightsResponse, MailboxAttribute,
    MailboxFilter, NotifyEvent, NotifyEventGroup, NotifySetParams, QuotaResource, ResponseCode,
    SearchCriteria, SequenceSet, StoreOperation, ThreadNode, UidRange, UntaggedResponse,
};
use daaki_imap::{
    IdleEvent, ImapConnection, MailboxName, SessionState, TcpKeepalive, TlsMode, TypedEvent,
};

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);

const HOST: &str = "127.0.0.1";
const IMAP_PORT: u16 = 14143;
const USER: &str = "testuser";
const PASS: &str = "testpass";

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

/// Search-test message fields used to build a minimal RFC 5322 message.
/// RFC 5322 Section 3.6.
#[derive(Clone, Copy)]
struct SearchTestMessage<'a> {
    from: &'a str,
    to: &'a str,
    cc: Option<&'a str>,
    bcc: Option<&'a str>,
    subject: &'a str,
    date: &'a str,
    message_id: &'a str,
    body: &'a str,
}

/// Thread-test message fields used to build minimal RFC 5322 messages for
/// server-side THREAD integration tests. RFC 5322 Sections 3.6 and 3.6.4.
#[derive(Clone, Copy)]
struct ThreadTestMessage<'a> {
    from: &'a str,
    to: &'a str,
    subject: &'a str,
    date: &'a str,
    message_id: &'a str,
    in_reply_to: Option<&'a str>,
    references: Option<&'a str>,
    body: &'a str,
}

/// Builds a minimal RFC 5322 message with caller-controlled headers for SEARCH
/// integration tests. RFC 5322 Section 3.6.
fn search_test_message(spec: SearchTestMessage<'_>) -> Vec<u8> {
    let mut message = String::new();
    let _ = write!(message, "From: {}\r\n", spec.from);
    let _ = write!(message, "To: {}\r\n", spec.to);
    if let Some(cc) = spec.cc {
        let _ = write!(message, "Cc: {cc}\r\n");
    }
    if let Some(bcc) = spec.bcc {
        let _ = write!(message, "Bcc: {bcc}\r\n");
    }
    let _ = write!(message, "Subject: {}\r\n", spec.subject);
    let _ = write!(message, "Date: {}\r\n", spec.date);
    let _ = write!(message, "Message-ID: {}\r\n", spec.message_id);
    message.push_str("MIME-Version: 1.0\r\n");
    message.push_str("Content-Type: text/plain; charset=utf-8\r\n");
    message.push_str("\r\n");
    message.push_str(spec.body);
    if !spec.body.ends_with("\r\n") {
        message.push_str("\r\n");
    }
    message.into_bytes()
}

/// Builds a minimal RFC 5322 message with optional reply-linking headers for
/// THREAD integration tests. RFC 5322 Sections 3.6 and 3.6.4.
fn thread_test_message(spec: ThreadTestMessage<'_>) -> Vec<u8> {
    let mut message = String::new();
    let _ = write!(message, "From: {}\r\n", spec.from);
    let _ = write!(message, "To: {}\r\n", spec.to);
    let _ = write!(message, "Subject: {}\r\n", spec.subject);
    let _ = write!(message, "Date: {}\r\n", spec.date);
    let _ = write!(message, "Message-ID: {}\r\n", spec.message_id);
    if let Some(in_reply_to) = spec.in_reply_to {
        let _ = write!(message, "In-Reply-To: {in_reply_to}\r\n");
    }
    if let Some(references) = spec.references {
        let _ = write!(message, "References: {references}\r\n");
    }
    message.push_str("MIME-Version: 1.0\r\n");
    message.push_str("Content-Type: text/plain; charset=utf-8\r\n");
    message.push_str("\r\n");
    message.push_str(spec.body);
    if !spec.body.ends_with("\r\n") {
        message.push_str("\r\n");
    }
    message.into_bytes()
}

/// Append two distinct messages to `mailbox` and return their UIDs in delivery
/// order for SEARCH integration tests. RFC 3501 Section 6.3.11.
async fn append_search_flag_messages(conn: &ImapConnection, mailbox: &str) -> (u32, u32) {
    let msg1 = search_test_message(SearchTestMessage {
        from: "alpha@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Flag criteria alpha",
        date: "Mon, 16 Mar 2026 00:00:00 +0000",
        message_id: "<search-flag-alpha@daaki>",
        body: "Alpha message body.\r\n",
    });
    let msg2 = search_test_message(SearchTestMessage {
        from: "beta@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Flag criteria beta",
        date: "Tue, 17 Mar 2026 00:00:00 +0000",
        message_id: "<search-flag-beta@daaki>",
        body: "Beta message body.\r\n",
    });

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        2,
        "expected exactly two messages for SEARCH flag criteria tests"
    );

    (
        fetches[0]
            .uid
            .expect("SEARCH flag test message must have a UID"),
        fetches[1]
            .uid
            .expect("SEARCH flag test message must have a UID"),
    )
}

/// Append SEARCH fixture messages to `mailbox` and return their assigned UIDs
/// in sequence order. RFC 3501 Section 6.3.11 appends messages, and RFC 3501
/// Section 6.4.8 (`UID FETCH`) retrieves the resulting UIDs.
async fn append_search_messages(
    conn: &ImapConnection,
    mailbox: &str,
    messages: &[SearchTestMessage<'_>],
) -> Vec<u32> {
    for spec in messages {
        let msg = search_test_message(*spec);
        conn.append(mailbox, &[], None, &msg, TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        messages.len(),
        "expected {} appended SEARCH fixture messages",
        messages.len()
    );

    fetches
        .into_iter()
        .map(|fetch| fetch.uid.expect("SEARCH fixture message must have a UID"))
        .collect()
}

/// Return whether Dovecot advertises NOTIFY for this session.
///
/// RFC 5465 Section 1 requires clients to discover support via CAPABILITY
/// before issuing NOTIFY commands.
async fn require_dovecot_notify(conn: &ImapConnection) -> bool {
    let has_notify = conn.capabilities().contains(&Capability::Notify);
    if !has_notify {
        eprintln!("skipping: Dovecot does not advertise NOTIFY");
    }
    has_notify
}

/// Serialize the Dovecot NOTIFY tests to reduce contention while they hold
/// multiple long-lived sessions open for asynchronous event delivery.
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(()))
}

/// 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
    }
}

/// Flush pending unsolicited responses into the typed event queue.
///
/// RFC 3501 Section 6.1.2: `NOOP` lets the server send pending 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 flush and drain events until a matching event arrives or the
/// deadline expires.
///
/// RFC 3501 Section 6.1.2: using `NOOP` as a synchronization point keeps
/// asynchronous NOTIFY assertions deterministic.
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
}

/// Return whether a drained event reports LIST data for the named mailbox.
///
/// RFC 5465 Section 5.4 delivers `MailboxName` notifications as unsolicited
/// LIST responses, which surface as `TypedEvent::MailboxEvent`.
fn is_mailbox_event_for(event: &TypedEvent, mailbox: &str) -> bool {
    matches!(event, TypedEvent::MailboxEvent(info) if info.name.as_str() == mailbox)
}

/// Return whether a drained event reports STATUS data for the named mailbox.
///
/// RFC 5465 Section 4 requires `NOTIFY SET STATUS` to deliver initial STATUS
/// responses before the tagged OK.
fn is_mailbox_status_for(event: &TypedEvent, mailbox: &str) -> bool {
    matches!(
        event,
        TypedEvent::Extension(resp)
            if matches!(
                resp.as_ref(),
                UntaggedResponse::MailboxStatus { mailbox: status_mailbox, .. }
                    if status_mailbox.as_str() == mailbox
            )
    )
}

/// Return whether a drained event reports a metadata change for the named
/// mailbox and entry.
///
/// RFC 5465 Section 5.6 delivers `MailboxMetadataChange` notifications as
/// unsolicited `METADATA` responses for the monitored mailbox.
fn is_metadata_change_for(event: &TypedEvent, mailbox: &str, entry_name: &str) -> bool {
    matches!(
        event,
        TypedEvent::Extension(resp)
            if matches!(
                resp.as_ref(),
                UntaggedResponse::Metadata {
                    mailbox: changed_mailbox,
                    entries,
                } if changed_mailbox.as_str().eq_ignore_ascii_case(mailbox)
                    && entries
                        .iter()
                        .any(|entry| entry.name.eq_ignore_ascii_case(entry_name))
            )
    )
}

/// Look up one metadata entry by name.
///
/// RFC 5464 Section 4.4 models a METADATA response as name/value pairs.
fn metadata_entry_named<'a>(
    entries: &'a [daaki_imap::types::MetadataEntry],
    name: &str,
) -> Option<&'a daaki_imap::types::MetadataEntry> {
    entries
        .iter()
        .find(|entry| entry.name.eq_ignore_ascii_case(name))
}

/// Register mailbox metadata change notifications for one mailbox.
///
/// RFC 5465 Section 5.6 defines `MailboxMetadataChange`. Servers that reject
/// the event with `[BADEVENT]` are treated as lacking that optional event in
/// this Docker environment.
async fn enable_mailbox_metadata_notify(conn: &ImapConnection, mailbox: &str) -> bool {
    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::Mailboxes(vec![mailbox.to_owned()]),
            vec![NotifyEvent::MailboxMetadataChange],
        )],
        false,
    );

    match conn.notify_set(params, TIMEOUT).await {
        Ok(()) => true,
        Err(daaki_imap::Error::No {
            code: Some(ResponseCode::BadEvent(_)),
            ..
        }) => {
            eprintln!("skipping: Dovecot rejected MailboxMetadataChange in NOTIFY SET");
            false
        }
        Err(err) => panic!("unexpected NOTIFY SET failure for MailboxMetadataChange: {err:?}"),
    }
}

// =========================================================================
// 1. Session state tracking (RFC 3501 Section 3)
// =========================================================================

/// Verify session_state() transitions correctly through the protocol lifecycle.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_session_state_transitions() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "should be NotAuthenticated after connect"
    );

    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "STARTTLS should not change session state"
    );

    conn.login(USER, PASS, TIMEOUT).await.unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "should be Authenticated after LOGIN"
    );

    conn.select("INBOX", TIMEOUT).await.unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "should be Selected after SELECT"
    );

    conn.close(TIMEOUT).await.unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Authenticated,
        "should be Authenticated after CLOSE"
    );

    conn.logout().await.unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Logout,
        "should be Logout after LOGOUT"
    );
}

// =========================================================================
// 2. AUTHENTICATE PLAIN — success path (RFC 4616)
// =========================================================================

/// AUTHENTICATE PLAIN with valid credentials on Dovecot.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_authenticate_plain_success() {
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();

    conn.authenticate_plain(USER, PASS, TIMEOUT).await.unwrap();
    assert_eq!(conn.session_state(), SessionState::Authenticated);

    // Verify we can use the connection after AUTHENTICATE PLAIN.
    let mailboxes = conn.list("", "INBOX", TIMEOUT).await.unwrap();
    assert!(mailboxes
        .iter()
        .any(|m| m.name.as_str().eq_ignore_ascii_case("INBOX")),);

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

// =========================================================================
// 3. UNSELECT (RFC 3691) — dedicated test
// =========================================================================

/// UNSELECT deselects without expunging deleted messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_unselect_preserves_deleted() {
    let conn = connect_dovecot().await;

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

    conn.append(mbox, &[], None, TEST_MSG, 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 but do NOT expunge.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // UNSELECT should NOT expunge (unlike CLOSE).
    conn.unselect(TIMEOUT).await.unwrap();
    assert_eq!(conn.session_state(), SessionState::Authenticated);

    // Re-select: the message should still be there.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 1,
        "UNSELECT must NOT expunge deleted messages"
    );

    // Clean up
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.expunge(TIMEOUT).await.unwrap();
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// UNSELECT in non-selected state should return BAD or NO.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_unselect_without_select() {
    let conn = connect_dovecot().await;

    let result = conn.unselect(TIMEOUT).await;
    assert!(
        result.is_err(),
        "UNSELECT without SELECT should fail, got {result:?}"
    );

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

// =========================================================================
// 4. TCP keepalive
// =========================================================================

/// set_keepalive should succeed on a live connection.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_keepalive() {
    let conn = connect_dovecot().await;

    conn.set_keepalive(TcpKeepalive::new(
        Duration::from_secs(60),
        Duration::from_secs(10),
    ))
    .await
    .unwrap();

    // Verify the connection still works.
    conn.noop(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `NOOP` is valid before authentication and must not authenticate the
/// session.
///
/// RFC 3501 Section 6.1.2 allows `NOOP` in any state, so a successful
/// pre-authenticated `NOOP` must leave the connection in the
/// not-authenticated state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_noop_before_auth_preserves_not_authenticated_state() {
    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(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

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

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "NOOP before LOGIN must not authenticate the session"
    );

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

/// CAPABILITY is valid before authentication and must not change the session
/// state while refreshing the cached capability snapshot.
///
/// RFC 3501 Section 6.1.1: CAPABILITY is valid in any state and returns the
/// server's current capability list. On the plaintext Dovecot test port, the
/// pre-authenticated capability list must still advertise `IMAP4rev1` and
/// `STARTTLS`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_capability_before_auth_updates_cache_without_authenticating() {
    init_tracing();
    let tls_config = build_danger_tls_config();
    let conn = ImapConnection::connect_with_tls_config(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

    let caps = conn.capability(TIMEOUT).await.unwrap();
    let returned = caps
        .iter()
        .map(Capability::as_imap_str)
        .collect::<BTreeSet<_>>();
    let cached = conn
        .capabilities()
        .iter()
        .map(Capability::as_imap_str)
        .collect::<BTreeSet<_>>();

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "CAPABILITY before LOGIN must not authenticate the session"
    );
    assert!(
        caps.contains(&Capability::Imap4Rev1),
        "RFC 3501 Section 6.1.1: CAPABILITY must include IMAP4rev1"
    );
    assert!(
        caps.contains(&Capability::StartTls),
        "plaintext Dovecot capability set must advertise STARTTLS before upgrade"
    );
    assert_eq!(
        returned, cached,
        "explicit CAPABILITY must refresh the cached capability snapshot"
    );

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

/// `ID` is valid before authentication and must not authenticate the
/// session.
///
/// RFC 2971 Section 3.1 allows `ID` in any state. A pre-authenticated `ID`
/// exchange therefore must leave the session in not-authenticated state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_id_before_auth_preserves_not_authenticated_state() {
    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(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

    if !has_capability(&conn, &Capability::Id) {
        eprintln!("skipping: Dovecot does not advertise ID");
        conn.logout().await.unwrap();
        return;
    }

    conn.id(
        &[("name", Some("daaki-preauth")), ("support-url", None)],
        TIMEOUT,
    )
    .await
    .unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "ID before LOGIN must not authenticate the session"
    );

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

/// `LOGOUT` is valid before authentication and must transition the
/// connection into the terminal logout state.
///
/// RFC 3501 Section 6.1.3 permits `LOGOUT` in any state and requires the
/// server to close the session. RFC 3501 Section 3.4 defines `Logout` as the
/// terminal session state, so follow-up commands must fail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_logout_before_auth_transitions_to_logout_state_and_rejects_followup_commands() {
    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(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config,
        TIMEOUT,
    )
    .await
    .unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::NotAuthenticated,
        "fresh plaintext Dovecot connections must begin in not-authenticated state"
    );

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

    assert_eq!(
        conn.session_state(),
        SessionState::Logout,
        "LOGOUT before authentication must transition to the terminal Logout state"
    );
    assert!(
        conn.noop(TIMEOUT).await.is_err(),
        "commands must fail after LOGOUT closes the session"
    );
}

/// CAPABILITY in selected state must preserve the selected mailbox and refresh
/// the cached capability list.
///
/// RFC 3501 Section 6.1.1: CAPABILITY is valid in any state. It must not
/// deselect the current mailbox or otherwise interfere with selected-state
/// commands.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_capability_selected_preserves_selected_mailbox() {
    let user = unique_name("capability-selected-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = unique_name("test-capability-selected");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let caps = conn.capability(TIMEOUT).await.unwrap();
    let returned = caps
        .iter()
        .map(Capability::as_imap_str)
        .collect::<BTreeSet<_>>();
    let cached = conn
        .capabilities()
        .iter()
        .map(Capability::as_imap_str)
        .collect::<BTreeSet<_>>();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "CAPABILITY must not leave selected state"
    );
    assert!(
        caps.contains(&Capability::Imap4Rev1),
        "RFC 3501 Section 6.1.1: CAPABILITY must include IMAP4rev1"
    );
    assert_eq!(
        returned, cached,
        "explicit CAPABILITY must refresh the cached capability snapshot"
    );

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "selected mailbox must remain usable after CAPABILITY"
    );

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

// =========================================================================
// 5. ESEARCH (RFC 4731)
// =========================================================================

/// UID SEARCH ESEARCH with MIN/MAX/COUNT/ALL return options.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_esearch() {
    let conn = connect_dovecot().await;

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

    // Append 3 messages.
    for _ in 0..3 {
        conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
            .await
            .unwrap();
    }

    let es = conn
        .uid_search_esearch("ALL", &["MIN", "MAX", "COUNT", "ALL"], TIMEOUT)
        .await
        .unwrap();

    assert!(es.uid, "UID SEARCH ESEARCH should set the UID flag");
    assert_eq!(es.count, Some(3), "expected COUNT=3, got {:?}", es.count);
    assert!(es.min.is_some(), "expected MIN to be present");
    assert!(es.max.is_some(), "expected MAX to be present");
    assert!(!es.all.is_empty(), "expected ALL to be present");

    let min = es.min.unwrap();
    let max = es.max.unwrap();
    assert!(max >= min, "MAX ({max}) should be >= MIN ({min})");

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

/// ESEARCH with COUNT only on an empty mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_esearch_empty_mailbox() {
    let conn = connect_dovecot().await;

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

    let es = conn
        .uid_search_esearch("ALL", &["COUNT"], TIMEOUT)
        .await
        .unwrap();

    assert_eq!(es.count, Some(0), "empty mailbox should have COUNT=0");

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

/// UID ESEARCH on an empty mailbox with MIN/MAX/ALL requested must report
/// COUNT=0, omit MIN/MAX, and return an empty ALL set.
///
/// RFC 4731 Section 3.1: result options such as MIN, MAX, and ALL describe
/// the matching set. When there are no matches, COUNT is zero and the other
/// data items must not invent message numbers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_esearch_empty_mailbox_all_min_max_absent() {
    let conn = connect_dovecot().await;

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

    let es = conn
        .uid_search_esearch("ALL", &["MIN", "MAX", "COUNT", "ALL"], TIMEOUT)
        .await
        .unwrap();

    assert!(
        es.uid,
        "UID SEARCH RETURN must mark the response as UID-based"
    );
    assert_eq!(es.count, Some(0), "empty mailbox must report COUNT=0");
    assert_eq!(es.min, None, "empty mailbox must not fabricate MIN");
    assert_eq!(es.max, None, "empty mailbox must not fabricate MAX");
    assert!(
        es.all.is_empty(),
        "empty mailbox must return an empty ALL set, got {:?}",
        es.all
    );

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

/// Non-UID SEARCH ESEARCH.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_esearch() {
    let conn = connect_dovecot().await;

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

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

    let es = conn
        .search_esearch("UNSEEN", &["COUNT"], TIMEOUT)
        .await
        .unwrap();

    assert!(!es.uid, "non-UID ESEARCH should not set the UID flag");
    assert_eq!(es.count, Some(1), "expected 1 unseen message");

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

/// Sequence-number ESEARCH on an empty mailbox with MIN/MAX/ALL requested
/// must report COUNT=0, omit MIN/MAX, and return an empty ALL set.
///
/// RFC 4731 Section 3.1: MIN, MAX, and ALL describe the set of matches for
/// the current search. If no message matches, the server must not synthesize
/// sequence numbers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_esearch_empty_mailbox_all_min_max_absent() {
    let conn = connect_dovecot().await;

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

    let es = conn
        .search_esearch("ALL", &["MIN", "MAX", "COUNT", "ALL"], TIMEOUT)
        .await
        .unwrap();

    assert!(!es.uid, "plain SEARCH RETURN must not set the UID marker");
    assert_eq!(es.count, Some(0), "empty mailbox must report COUNT=0");
    assert_eq!(es.min, None, "empty mailbox must not fabricate MIN");
    assert_eq!(es.max, None, "empty mailbox must not fabricate MAX");
    assert!(
        es.all.is_empty(),
        "empty mailbox must return an empty ALL set, got {:?}",
        es.all
    );

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

/// `SEARCH RETURN (ALL ...)` must report sequence numbers, not UIDs, after an
/// expunge creates a UID/sequence-number gap.
///
/// RFC 4731 Section 3.1 defines `ALL` as the set of matching message numbers
/// for `SEARCH RETURN`, while RFC 3501 Section 2.3.1.2 keeps sequence numbers
/// distinct from immutable UIDs.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_esearch_all_uses_sequence_numbers_after_uid_gap() {
    let conn = connect_dovecot().await;
    let mbox = unique_name("test-esearch-return-all-seq-gap");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    create_uid_gap(&conn, &mbox).await;

    for label in ["esearch-seq-gap-a", "esearch-seq-gap-b"] {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {label}\r\n\
             Date: 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {label}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let live_uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .iter()
        .map(|fetch| fetch.uid.expect("FETCH must include UID"))
        .collect();
    assert_eq!(
        live_uids.len(),
        2,
        "expected exactly two live messages after creating the UID gap"
    );

    let esearch = conn
        .search_esearch("ALL", &["MIN", "MAX", "COUNT", "ALL"], TIMEOUT)
        .await
        .unwrap();

    assert!(
        !esearch.uid,
        "SEARCH RETURN must remain sequence-number based"
    );
    assert_eq!(esearch.count, Some(2), "COUNT must be 2");
    assert_eq!(
        esearch.min,
        Some(1),
        "MIN must be the first sequence number"
    );
    assert_eq!(esearch.max, Some(2), "MAX must be the last sequence number");
    assert_eq!(
        expand_uid_ranges(&esearch.all),
        vec![1, 2],
        "SEARCH RETURN ALL must report sequence numbers"
    );
    assert_ne!(
        live_uids,
        vec![1, 2],
        "setup must create a divergence between sequence numbers and UIDs"
    );

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

/// `UID SEARCH RETURN (ALL ...)` must report immutable UIDs, not current
/// sequence numbers, after earlier messages have been expunged.
///
/// RFC 4731 Section 3.1 defines `ALL` as the matching UID set for `UID SEARCH
/// RETURN`, and RFC 3501 Section 2.3.1.1 requires UIDs to remain immutable.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_esearch_all_uses_uids_after_uid_gap() {
    let conn = connect_dovecot().await;
    let mbox = unique_name("test-esearch-return-all-uid-gap");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    create_uid_gap(&conn, &mbox).await;

    for label in ["esearch-uid-gap-a", "esearch-uid-gap-b"] {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {label}\r\n\
             Date: 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {label}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let live_uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .iter()
        .map(|fetch| fetch.uid.expect("FETCH must include UID"))
        .collect();
    assert_eq!(
        live_uids.len(),
        2,
        "expected exactly two live messages after creating the UID gap"
    );

    let esearch = conn
        .uid_search_esearch("ALL", &["MIN", "MAX", "COUNT", "ALL"], TIMEOUT)
        .await
        .unwrap();

    assert!(esearch.uid, "UID SEARCH RETURN must set the UID marker");
    assert_eq!(esearch.count, Some(2), "COUNT must be 2");
    assert_eq!(
        esearch.min,
        Some(*live_uids.first().expect("must have at least one UID")),
        "MIN must be the first live UID"
    );
    assert_eq!(
        esearch.max,
        Some(*live_uids.last().expect("must have at least one UID")),
        "MAX must be the last live UID"
    );
    assert_eq!(
        expand_uid_ranges(&esearch.all),
        live_uids,
        "UID SEARCH RETURN ALL must report UIDs"
    );

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

// =========================================================================
// 6. MOVE by sequence number (RFC 6851)
// =========================================================================

/// move_messages (non-UID MOVE) moves by sequence number.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_move_by_sequence() {
    let conn = connect_dovecot().await;

    let src = &format!("test-move-seq-src-{}", std::process::id());
    let dst = &format!("test-move-seq-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_MSG, TIMEOUT)
        .await
        .unwrap();

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

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

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

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

// =========================================================================
// 7. STORE operations — Remove, Replace, Silent variants
// =========================================================================

/// STORE Remove — remove a specific flag.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_remove_flag() {
    let conn = connect_dovecot().await;

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

    // Append with \Seen and \Flagged.
    conn.append(mbox, &[Flag::Seen, Flag::Flagged], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

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

    // Remove \Flagged, keep \Seen.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Remove,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).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();
    conn.delete(mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// STORE Replace — replace all flags with a new set.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_replace_flags() {
    let conn = connect_dovecot().await;

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

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

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

    // Replace all flags with just \Answered.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Replace,
        &[Flag::Answered],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Answered),
        "\\Answered should be set after replace"
    );
    assert!(
        !flags.contains(&Flag::Seen),
        "\\Seen should be gone after replace"
    );
    assert!(
        !flags.contains(&Flag::Flagged),
        "\\Flagged should be gone after replace"
    );

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

/// STORE AddSilent — server should NOT send updated flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_silent() {
    let conn = connect_dovecot().await;

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

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

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

    // Silent STORE — the returned FETCH responses should have no flags
    // (server uses .SILENT suffix to suppress flags in the response).
    let result = conn
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::AddSilent,
            &[Flag::Flagged],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();

    // Silent should return empty or no FETCH responses.
    // Some servers still return FETCH with UID only — that's OK.
    for fetch in &result.fetches {
        if let Some(flags) = &fetch.flags {
            // If the server does return flags, the AddSilent wasn't fully silent,
            // but the operation should still succeed. Just verify it's not an error.
            let _ = flags;
        }
    }

    // Verify the flag was actually set.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&Flag::Flagged),
        "flag should be set even with silent STORE"
    );

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

/// STORE RemoveSilent — remove flag without response.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_remove_silent() {
    let conn = connect_dovecot().await;

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

    conn.append(mbox, &[Flag::Seen, Flag::Flagged], None, TEST_MSG, 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(),
        StoreOperation::RemoveSilent,
        &[Flag::Seen],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

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

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

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

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

    conn.append(mbox, &[Flag::Seen], None, TEST_MSG, 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(),
        StoreOperation::ReplaceSilent,
        &[Flag::Draft],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(flags.contains(&Flag::Draft), "\\Draft should be set");
    assert!(!flags.contains(&Flag::Seen), "\\Seen should be gone");

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

/// Custom keyword flags via STORE.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_custom_keyword() {
    let conn = connect_dovecot().await;

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

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

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

    // Add a custom keyword.
    let kw = Flag::Custom("$Important".to_string());
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Add,
        std::slice::from_ref(&kw),
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(
        flags.contains(&kw),
        "custom keyword $Important should be set, got {flags:?}"
    );

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

// =========================================================================
// 8. APPEND edge cases
// =========================================================================

/// APPEND with explicit internaldate.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_with_date() {
    let conn = connect_dovecot().await;

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

    // RFC 3501 date-time format: "DD-Mon-YYYY HH:MM:SS +ZZZZ"
    let date = "15-Mar-2025 10:30:00 +0000";
    conn.append(mbox, &[Flag::Seen], Some(date), TEST_MSG, TIMEOUT)
        .await
        .unwrap();

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

    let msg = &fetches[0];
    let idate = msg.internal_date.as_deref().expect("expected INTERNALDATE");
    // The server should return a date containing "2025".
    assert!(
        idate.contains("2025"),
        "INTERNALDATE should reflect the specified date, got: {idate}"
    );

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

/// APPEND with multiple flags set simultaneously.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_multiple_flags() {
    let conn = connect_dovecot().await;

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

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_ref().unwrap();
    assert!(flags.contains(&Flag::Seen));
    assert!(flags.contains(&Flag::Flagged));
    assert!(flags.contains(&Flag::Answered));
    assert!(flags.contains(&Flag::Draft));

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

/// APPEND must ignore wildcard `\\*` while preserving valid user-specified
/// flags.
///
/// RFC 3501 Section 6.3.11: APPEND accepts a parenthesized list of message
/// flags. RFC 3501 Section 9: `\\*` is a flag-perm marker, not a storable
/// message flag, so the client must not encode it in the APPEND flag list.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_filters_server_only_flags() {
    let conn = connect_dovecot().await;

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

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one appended message");

    let flags = fetches[0]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(flags.contains(&Flag::Seen), "\\Seen must be preserved");
    assert!(
        flags.contains(&Flag::Flagged),
        "\\Flagged must be preserved"
    );
    assert!(
        !flags.contains(&Flag::Wildcard),
        "\\* must not be sent as an APPEND flag"
    );

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

/// APPEND must store user-defined keywords alongside system flags.
///
/// RFC 3501 Section 2.3.2 defines keywords as message flags, and RFC 3501
/// Section 6.3.11 permits APPEND to set the initial flag list for the
/// appended message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_custom_keyword_roundtrips() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-append-keyword");
    let keyword = "$DaakiAppendKeyword".to_owned();
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.append(
        &mailbox,
        &[Flag::Seen, Flag::Custom(keyword.clone())],
        None,
        TEST_MSG,
        TIMEOUT,
    )
    .await
    .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected one appended message");

    let flags = fetches[0]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(flags.contains(&Flag::Seen), "\\Seen must be preserved");
    assert!(
        flags.contains(&Flag::Custom(keyword)),
        "custom APPEND keywords must round-trip through FETCH FLAGS"
    );

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

/// APPEND to a different mailbox while selected must not change the current
/// selected mailbox.
///
/// RFC 3501 Section 6.3.11 permits APPEND in selected state, and commands that
/// do not open another mailbox must leave the current selection usable for
/// follow-up selected-state commands.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_to_other_mailbox_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = prepare_selected_mailbox(&conn, "test-append-selected-src").await;
    let target_mailbox = unique_name("test-append-selected-dst");
    conn.create(&target_mailbox, TIMEOUT).await.unwrap();

    conn.append(&target_mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    assert_selected_mailbox_still_usable(&conn, "APPEND").await;

    conn.select(&target_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "APPEND must store the new message in the destination mailbox"
    );

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

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

    let result = conn
        .append("nonexistent-box-999", &[], None, TEST_MSG, TIMEOUT)
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "APPEND to nonexistent mailbox should return No, got {result:?}"
    );

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

/// APPEND a large message (100KB+) to verify chunked handling.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_large_message() {
    let conn = connect_dovecot().await;

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

    // Build a ~100KB message.
    let body_line = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ".repeat(3);
    let mut large_msg = b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Large message test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <large-msg@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n"
        .to_vec();
    // ~100KB of body lines
    for _ in 0..900 {
        large_msg.extend_from_slice(body_line.as_bytes());
        large_msg.extend_from_slice(b"\r\n");
    }

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Rfc822Size],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());
    let size = fetches[0].rfc822_size.unwrap();
    assert!(size >= 90_000, "RFC822.SIZE should be >= 90KB, got {size}");

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

// =========================================================================
// 9. FETCH edge cases
// =========================================================================

/// FETCH INTERNALDATE and RFC822.SIZE together.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_internaldate_and_size() {
    let conn = connect_dovecot().await;

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

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

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

    let msg = &fetches[0];
    assert!(msg.uid.is_some());
    assert!(msg.internal_date.is_some(), "INTERNALDATE must be present");
    assert!(msg.rfc822_size.is_some(), "RFC822.SIZE must be present");
    assert!(msg.flags.is_some(), "FLAGS must be present");

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

/// FETCH BODY[HEADER] — fetch only message headers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_body_header() {
    let conn = connect_dovecot().await;

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

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

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

    let msg = &fetches[0];
    assert!(!msg.body_sections.is_empty(), "expected BODY[HEADER]");
    let data = msg.body_sections[0]
        .data
        .as_ref()
        .expect("expected header data");
    let text = String::from_utf8_lossy(data);
    assert!(text.contains("Subject:"), "headers should contain Subject");
    assert!(text.contains("From:"), "headers should contain From");
    assert!(
        !text.contains("Hello from extended"),
        "headers should NOT contain body text"
    );

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

/// FETCH BODY[TEXT] — fetch only message body.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_body_text() {
    let conn = connect_dovecot().await;

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

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

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

    let msg = &fetches[0];
    assert!(!msg.body_sections.is_empty(), "expected BODY[TEXT]");
    let data = msg.body_sections[0]
        .data
        .as_ref()
        .expect("expected body data");
    let text = String::from_utf8_lossy(data);
    assert!(
        text.contains("Hello from extended"),
        "BODY[TEXT] should contain message body"
    );

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

/// FETCH BODY[1] — fetch first MIME part of a multipart message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_body_part() {
    let conn = connect_dovecot().await;

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

    let multipart_msg = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multipart for part fetch\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <part-fetch@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"part-boundary\"\r\n\
\r\n\
--part-boundary\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
First part text content.\r\n\
--part-boundary\r\n\
Content-Type: text/html; charset=utf-8\r\n\
\r\n\
<p>Second part HTML</p>\r\n\
--part-boundary--\r\n";

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

    // Fetch BODY[1] — first MIME part (text/plain).
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: false,
                    section: Some("1".into()),
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());

    let msg = &fetches[0];
    assert!(!msg.body_sections.is_empty(), "expected BODY[1]");
    let data = msg.body_sections[0]
        .data
        .as_ref()
        .expect("expected part data");
    let text = String::from_utf8_lossy(data);
    assert!(
        text.contains("First part text content"),
        "BODY[1] should contain first part text, got: {text}"
    );

    // Fetch BODY[2] — second MIME part (text/html).
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: false,
                    section: Some("2".into()),
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());
    let data = fetches[0].body_sections[0]
        .data
        .as_ref()
        .expect("expected part 2 data");
    let text = String::from_utf8_lossy(data);
    assert!(
        text.contains("Second part HTML"),
        "BODY[2] should contain second part HTML, got: {text}"
    );

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

/// FETCH RFC822.HEADER returns headers only, omits the message body, and
/// preserves the unseen state because it is equivalent to
/// `BODY.PEEK[HEADER]` (RFC 3501 Section 6.4.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_rfc822_header_alias_returns_headers_only() {
    let conn = connect_dovecot().await;

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

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Rfc822Header],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one fetched message");

    let uid = fetches[0].uid.expect("expected UID");
    let section = fetches[0]
        .body_sections
        .first()
        .expect("expected RFC822.HEADER body section");
    let data = section
        .data
        .as_ref()
        .expect("expected RFC822.HEADER payload");
    let text = String::from_utf8_lossy(data);

    assert!(
        text.contains("Subject: Extended test"),
        "RFC822.HEADER should include message headers"
    );
    assert!(
        !text.contains("Hello from extended integration tests."),
        "RFC822.HEADER must not include the message body"
    );

    let flags = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = flags[0].flags.as_deref().unwrap_or_default();
    assert!(
        !flags.contains(&Flag::Seen),
        "RFC822.HEADER must preserve the unseen state because it is equivalent to BODY.PEEK[HEADER]"
    );

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

/// FETCH RFC822.TEXT returns only the message body and sets `\\Seen`
/// because it is equivalent to `BODY[TEXT]` (RFC 3501 Section 6.4.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_rfc822_text_alias_returns_body_and_sets_seen() {
    let conn = connect_dovecot().await;

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

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Rfc822Text],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one fetched message");

    let uid = fetches[0].uid.expect("expected UID");
    let section = fetches[0]
        .body_sections
        .first()
        .expect("expected RFC822.TEXT body section");
    let data = section.data.as_ref().expect("expected RFC822.TEXT payload");
    let text = String::from_utf8_lossy(data);

    assert!(
        !text.contains("Subject: Extended test"),
        "RFC822.TEXT must not include message headers"
    );
    assert!(
        text.contains("Hello from extended integration tests."),
        "RFC822.TEXT should include the message body"
    );

    let flags = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        flags[0]
            .flags
            .as_deref()
            .unwrap_or_default()
            .contains(&Flag::Seen),
        "RFC822.TEXT should set \\\\Seen because it is a non-PEEK body fetch"
    );

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

/// FETCH RFC822 returns the full message and sets `\\Seen` because it is
/// equivalent to `BODY[]` (RFC 3501 Section 6.4.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_rfc822_alias_returns_full_message_and_sets_seen() {
    let conn = connect_dovecot().await;

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

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Rfc822],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected exactly one fetched message");

    let uid = fetches[0].uid.expect("expected UID");
    let section = fetches[0]
        .body_sections
        .first()
        .expect("expected RFC822 body section");
    let data = section.data.as_ref().expect("expected RFC822 payload");
    let text = String::from_utf8_lossy(data);

    assert!(
        text.contains("Subject: Extended test"),
        "RFC822 should include message headers"
    );
    assert!(
        text.contains("Hello from extended integration tests."),
        "RFC822 should include the message body"
    );

    let flags = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        flags[0]
            .flags
            .as_deref()
            .unwrap_or_default()
            .contains(&Flag::Seen),
        "RFC822 should set \\\\Seen because it is equivalent to BODY[]"
    );

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

/// FETCH with a range of sequence numbers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_sequence_range() {
    let conn = connect_dovecot().await;

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

    for _ in 0..5 {
        conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
            .await
            .unwrap();
    }

    // Re-select to refresh EXISTS.
    conn.select(mbox, TIMEOUT).await.unwrap();

    // Fetch range 2:4.
    let fetches = conn
        .fetch(
            &SequenceSet::new("2:4").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        3,
        "FETCH 2:4 should return 3 messages, got {}",
        fetches.len()
    );

    // Verify sequence numbers.
    let seqs: Vec<u32> = fetches.iter().map(|f| f.seq).collect();
    assert_eq!(seqs, vec![2, 3, 4], "sequences should be [2, 3, 4]");

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

/// FETCH on empty mailbox returns empty result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_empty_mailbox() {
    let conn = connect_dovecot().await;

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        fetches.is_empty(),
        "FETCH on empty mailbox should return empty, got {} results",
        fetches.len()
    );

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

/// FETCH RFC822 — full message content.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_rfc822() {
    let conn = connect_dovecot().await;

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

    conn.append(mbox, &[], None, TEST_MSG, 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 data = fetches[0].body_sections[0]
        .data
        .as_ref()
        .expect("expected RFC822 data");
    let text = String::from_utf8_lossy(data);
    // Should contain both headers and body.
    assert!(text.contains("Subject: Extended test"));
    assert!(text.contains("Hello from extended"));

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

// =========================================================================
// 10. SEARCH edge cases (RFC 3501 Section 6.4.4)
// =========================================================================

/// SEARCH with complex criteria — FROM, TO, SUBJECT, SINCE, BODY.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_complex_criteria() {
    let conn = connect_dovecot().await;

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

    // Append messages with different attributes.
    let msg_alice = b"\
From: alice@example.com\r\n\
To: bob@example.com\r\n\
Subject: Project update\r\n\
Date: Mon, 10 Mar 2025 10:00:00 +0000\r\n\
Message-ID: <search-alice@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Meeting notes from Monday.\r\n";

    let msg_bob = b"\
From: bob@example.com\r\n\
To: alice@example.com\r\n\
Subject: Re: Budget review\r\n\
Date: Tue, 11 Mar 2025 14:00:00 +0000\r\n\
Message-ID: <search-bob@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Budget figures attached.\r\n";

    let msg_carol = b"\
From: carol@example.com\r\n\
To: bob@example.com\r\n\
Subject: Lunch plans\r\n\
Date: Wed, 12 Mar 2025 09:00:00 +0000\r\n\
Message-ID: <search-carol@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Lunch at noon?\r\n";

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

    // SEARCH FROM alice
    let uids = conn
        .uid_search("FROM alice@example.com", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(uids.ids.len(), 1, "FROM alice should match 1 message");

    // SEARCH TO bob
    let uids = conn
        .uid_search("TO bob@example.com", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(uids.ids.len(), 2, "TO bob should match 2 messages");

    // SEARCH SUBJECT "Budget"
    let uids = conn.uid_search("SUBJECT Budget", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 1, "SUBJECT Budget should match 1 message");

    // SEARCH UNSEEN
    let uids = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 2, "UNSEEN should match 2 messages");

    // SEARCH SEEN
    let uids = conn.uid_search("SEEN", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 1, "SEEN should match 1 message");

    // SEARCH BODY "Budget"
    let uids = conn.uid_search("BODY Budget", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 1, "BODY Budget should match 1 message");

    // SEARCH NOT FROM carol
    let uids = conn
        .uid_search("NOT FROM carol@example.com", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(uids.ids.len(), 2, "NOT FROM carol should match 2 messages");

    // SEARCH OR FROM alice FROM carol
    let uids = conn
        .uid_search("OR FROM alice@example.com FROM carol@example.com", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        uids.ids.len(),
        2,
        "OR FROM alice FROM carol should match 2 messages"
    );

    // SEARCH on empty result set.
    let uids = conn
        .uid_search("FROM nonexistent@nowhere.com", TIMEOUT)
        .await
        .unwrap();
    assert!(
        uids.ids.is_empty(),
        "no message from nonexistent should match"
    );

    // SEARCH LARGER — messages should be > 50 bytes.
    let uids = conn.uid_search("LARGER 50", TIMEOUT).await.unwrap();
    assert_eq!(uids.ids.len(), 3, "all messages should be LARGER 50");

    // SEARCH SMALLER — no messages should be < 50 bytes.
    let uids = conn.uid_search("SMALLER 50", TIMEOUT).await.unwrap();
    assert!(uids.ids.is_empty(), "no messages should be SMALLER 50");

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

/// SEARCH HEADER must match the value of the named header field.
///
/// RFC 3501 Section 6.4.4 defines `HEADER header-fld-name string` as a
/// header-field substring search.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_header_matches_named_header_value() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-header");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let uids = append_search_messages(
        &conn,
        &mbox,
        &[
            SearchTestMessage {
                from: "alpha@example.com",
                to: "recipient@example.com",
                cc: Some("team@example.com"),
                bcc: None,
                subject: "Header search alpha",
                date: "Mon, 16 Mar 2026 00:00:00 +0000",
                message_id: "<header-target@daaki>",
                body: "Header fixture alpha.\r\n",
            },
            SearchTestMessage {
                from: "beta@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Header search beta",
                date: "Tue, 17 Mar 2026 00:00:00 +0000",
                message_id: "<header-other@daaki>",
                body: "Header fixture beta.\r\n",
            },
        ],
    )
    .await;

    let criteria = SearchCriteria::new()
        .header("Message-ID", "<header-target@daaki>")
        .unwrap();
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[0]],
        "HEADER Message-ID must match only the targeted message"
    );

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

/// SEARCH BODY must match the message body and not header fields.
///
/// RFC 3501 Section 6.4.4 defines `BODY string` as a body-text search rather
/// than a header search.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_body_excludes_header_only_matches() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-body");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let uids = append_search_messages(
        &conn,
        &mbox,
        &[
            SearchTestMessage {
                from: "alpha@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "body-token-007 header only",
                date: "Mon, 16 Mar 2026 00:00:00 +0000",
                message_id: "<body-header-only@daaki>",
                body: "This body does not contain the token.\r\n",
            },
            SearchTestMessage {
                from: "beta@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Regular subject",
                date: "Tue, 17 Mar 2026 00:00:00 +0000",
                message_id: "<body-body-match@daaki>",
                body: "This body contains body-token-007 in the text.\r\n",
            },
        ],
    )
    .await;

    let criteria = SearchCriteria::new().body("body-token-007").unwrap();
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[1]],
        "BODY must not match a token that appears only in headers"
    );

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

/// SEARCH TEXT must match either message headers or the message body.
///
/// RFC 3501 Section 6.4.4 defines `TEXT string` as a search across the
/// message header and body.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_text_matches_header_or_body() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-text");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let uids = append_search_messages(
        &conn,
        &mbox,
        &[
            SearchTestMessage {
                from: "alpha@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "text-token-019 header match",
                date: "Mon, 16 Mar 2026 00:00:00 +0000",
                message_id: "<text-header@daaki>",
                body: "No token in this body.\r\n",
            },
            SearchTestMessage {
                from: "beta@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Regular subject",
                date: "Tue, 17 Mar 2026 00:00:00 +0000",
                message_id: "<text-body@daaki>",
                body: "The text-token-019 appears here in the body.\r\n",
            },
        ],
    )
    .await;

    let criteria = SearchCriteria::new().text("text-token-019").unwrap();
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids, uids,
        "TEXT must match tokens present in either headers or body"
    );

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

/// SEARCH BEFORE must compare against the message internal date.
///
/// RFC 3501 Section 6.4.4 defines `BEFORE date` using the internal date, not
/// the RFC 5322 `Date:` header.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_before_uses_internal_date() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-before");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let message = search_test_message(SearchTestMessage {
        from: "date@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Internal date fixture",
        date: "Wed, 01 Jan 2020 00:00:00 +0000",
        message_id: "<internal-date@daaki>",
        body: "Same RFC 5322 Date header for all APPEND date tests.\r\n",
    });
    conn.append(
        &mbox,
        &[],
        Some("01-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(
        &mbox,
        &[],
        Some("02-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(
        &mbox,
        &[],
        Some("03-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();

    let uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .into_iter()
        .map(|fetch| fetch.uid.expect("date fixture must have a UID"))
        .collect();
    assert_eq!(uids.len(), 3, "expected three date fixture messages");

    let criteria = SearchCriteria::new().before("02-Jan-2025").unwrap();
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[0]],
        "BEFORE must use APPEND internal dates rather than the Date header"
    );

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

/// SEARCH ON must compare against the message internal date.
///
/// RFC 3501 Section 6.4.4 defines `ON date` using the internal date, not the
/// RFC 5322 `Date:` header.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_on_uses_internal_date() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-on");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let message = search_test_message(SearchTestMessage {
        from: "date@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Internal date fixture",
        date: "Wed, 01 Jan 2020 00:00:00 +0000",
        message_id: "<internal-date-on@daaki>",
        body: "Same RFC 5322 Date header for all APPEND date tests.\r\n",
    });
    conn.append(
        &mbox,
        &[],
        Some("01-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(
        &mbox,
        &[],
        Some("02-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(
        &mbox,
        &[],
        Some("03-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();

    let uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .into_iter()
        .map(|fetch| fetch.uid.expect("date fixture must have a UID"))
        .collect();
    assert_eq!(uids.len(), 3, "expected three date fixture messages");

    let criteria = SearchCriteria::new().on("02-Jan-2025").unwrap();
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[1]],
        "ON must match only messages whose internal date falls on the given day"
    );

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

/// SEARCH SINCE must compare against the message internal date.
///
/// RFC 3501 Section 6.4.4 defines `SINCE date` using the internal date, not
/// the RFC 5322 `Date:` header.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_since_uses_internal_date() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-since");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let message = search_test_message(SearchTestMessage {
        from: "date@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Internal date fixture",
        date: "Wed, 01 Jan 2020 00:00:00 +0000",
        message_id: "<internal-date-since@daaki>",
        body: "Same RFC 5322 Date header for all APPEND date tests.\r\n",
    });
    conn.append(
        &mbox,
        &[],
        Some("01-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(
        &mbox,
        &[],
        Some("02-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.append(
        &mbox,
        &[],
        Some("03-Jan-2025 12:00:00 +0000"),
        &message,
        TIMEOUT,
    )
    .await
    .unwrap();

    let uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .into_iter()
        .map(|fetch| fetch.uid.expect("date fixture must have a UID"))
        .collect();
    assert_eq!(uids.len(), 3, "expected three date fixture messages");

    let criteria = SearchCriteria::new().since("02-Jan-2025").unwrap();
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[1], uids[2]],
        "SINCE must include messages whose internal date is on or after the given day"
    );

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

/// SEARCH KEYWORD and UNKEYWORD must follow per-message keyword flags.
///
/// RFC 3501 Section 6.4.4 defines `KEYWORD` and `UNKEYWORD` as searches over
/// keyword flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_keyword_and_unkeyword_follow_custom_keyword() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-keyword");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let uids = append_search_messages(
        &conn,
        &mbox,
        &[
            SearchTestMessage {
                from: "alpha@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Keyword alpha",
                date: "Mon, 16 Mar 2026 00:00:00 +0000",
                message_id: "<keyword-alpha@daaki>",
                body: "Keyword fixture alpha.\r\n",
            },
            SearchTestMessage {
                from: "beta@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Keyword beta",
                date: "Tue, 17 Mar 2026 00:00:00 +0000",
                message_id: "<keyword-beta@daaki>",
                body: "Keyword fixture beta.\r\n",
            },
        ],
    )
    .await;

    conn.uid_store(
        &SequenceSet::new(uids[0].to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Custom("projectx".to_owned())],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let keyword_matches = conn
        .uid_search(SearchCriteria::new().keyword("projectx").unwrap(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        keyword_matches.ids,
        vec![uids[0]],
        "KEYWORD must match only the message carrying the custom keyword"
    );

    let unkeyword_matches = conn
        .uid_search(
            SearchCriteria::new().unkeyword("projectx").unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        unkeyword_matches.ids,
        vec![uids[1]],
        "UNKEYWORD must exclude messages carrying the custom keyword"
    );

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

/// SEARCH OR must preserve grouping for compound operands.
///
/// RFC 3501 Section 6.4.4 defines `OR search-key search-key`; compound
/// operands must remain grouped so `A AND B` on each side is preserved.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_or_parenthesizes_compound_operands() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-or");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let uids = append_search_messages(
        &conn,
        &mbox,
        &[
            SearchTestMessage {
                from: "alice@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Project update",
                date: "Mon, 16 Mar 2026 00:00:00 +0000",
                message_id: "<or-alice-project@daaki>",
                body: "Left operand match.\r\n",
            },
            SearchTestMessage {
                from: "bob@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Budget review",
                date: "Tue, 17 Mar 2026 00:00:00 +0000",
                message_id: "<or-bob-budget@daaki>",
                body: "Right operand match.\r\n",
            },
            SearchTestMessage {
                from: "alice@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Lunch plans",
                date: "Wed, 18 Mar 2026 00:00:00 +0000",
                message_id: "<or-alice-lunch@daaki>",
                body: "Should not match either compound operand.\r\n",
            },
        ],
    )
    .await;

    let left = SearchCriteria::new()
        .from("alice@example.com")
        .unwrap()
        .subject("Project")
        .unwrap();
    let right = SearchCriteria::new()
        .from("bob@example.com")
        .unwrap()
        .subject("Budget")
        .unwrap();
    let criteria = SearchCriteria::new().or(&left, &right);
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[0], uids[1]],
        "OR must keep each compound operand grouped"
    );

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

/// SEARCH NOT must preserve grouping for compound operands.
///
/// RFC 3501 Section 6.4.4 defines `NOT search-key`; compound operands must
/// remain grouped so `NOT (A AND B)` does not degrade into `NOT A AND B`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_not_parenthesizes_compound_operand() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-not");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let uids = append_search_messages(
        &conn,
        &mbox,
        &[
            SearchTestMessage {
                from: "alice@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Project update",
                date: "Mon, 16 Mar 2026 00:00:00 +0000",
                message_id: "<not-alice-project@daaki>",
                body: "Only this message should be negated away.\r\n",
            },
            SearchTestMessage {
                from: "bob@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Budget review",
                date: "Tue, 17 Mar 2026 00:00:00 +0000",
                message_id: "<not-bob-budget@daaki>",
                body: "Should remain after negation.\r\n",
            },
            SearchTestMessage {
                from: "alice@example.com",
                to: "recipient@example.com",
                cc: None,
                bcc: None,
                subject: "Lunch plans",
                date: "Wed, 18 Mar 2026 00:00:00 +0000",
                message_id: "<not-alice-lunch@daaki>",
                body: "Should remain because the subject differs.\r\n",
            },
        ],
    )
    .await;

    let compound = SearchCriteria::new()
        .from("alice@example.com")
        .unwrap()
        .subject("Project")
        .unwrap();
    let criteria = SearchCriteria::new().not(&compound);
    let matches = conn.uid_search(criteria, TIMEOUT).await.unwrap();
    assert_eq!(
        matches.ids,
        vec![uids[1], uids[2]],
        "NOT must negate the whole compound operand"
    );

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

/// UID SEARCH SEEN and UNSEEN track the `\\Seen` flag per RFC 3501 Section
/// 6.4.4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_seen_and_unseen_follow_seen_flag() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-seen");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (seen_uid, unseen_uid) = append_search_flag_messages(&conn, &mbox).await;

    conn.uid_store(
        &SequenceSet::new(seen_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Seen],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let seen = conn
        .uid_search(SearchCriteria::new().seen(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        seen.ids,
        vec![seen_uid],
        "SEEN should match only the message marked \\\\Seen"
    );

    let unseen = conn
        .uid_search(SearchCriteria::new().unseen(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        unseen.ids,
        vec![unseen_uid],
        "UNSEEN should match only the message without \\\\Seen"
    );

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

/// UID SEARCH ANSWERED and UNANSWERED track the `\\Answered` flag per RFC
/// 3501 Section 6.4.4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_answered_and_unanswered_follow_answered_flag() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-answered");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (answered_uid, unanswered_uid) = append_search_flag_messages(&conn, &mbox).await;

    conn.uid_store(
        &SequenceSet::new(answered_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Answered],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let answered = conn
        .uid_search(SearchCriteria::new().answered(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        answered.ids,
        vec![answered_uid],
        "ANSWERED should match only the message marked \\\\Answered"
    );

    let unanswered = conn
        .uid_search(SearchCriteria::new().unanswered(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        unanswered.ids,
        vec![unanswered_uid],
        "UNANSWERED should match only the message without \\\\Answered"
    );

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

/// UID SEARCH FLAGGED and UNFLAGGED track the `\\Flagged` flag per RFC 3501
/// Section 6.4.4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_flagged_and_unflagged_follow_flagged_flag() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-flagged");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (flagged_uid, unflagged_uid) = append_search_flag_messages(&conn, &mbox).await;

    conn.uid_store(
        &SequenceSet::new(flagged_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let flagged = conn
        .uid_search(SearchCriteria::new().flagged(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        flagged.ids,
        vec![flagged_uid],
        "FLAGGED should match only the message marked \\\\Flagged"
    );

    let unflagged = conn
        .uid_search(SearchCriteria::new().unflagged(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        unflagged.ids,
        vec![unflagged_uid],
        "UNFLAGGED should match only the message without \\\\Flagged"
    );

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

/// UID SEARCH DELETED and UNDELETED track the `\\Deleted` flag per RFC 3501
/// Section 6.4.4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_deleted_and_undeleted_follow_deleted_flag() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-deleted");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (deleted_uid, undeleted_uid) = append_search_flag_messages(&conn, &mbox).await;

    conn.uid_store(
        &SequenceSet::new(deleted_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let deleted = conn
        .uid_search(SearchCriteria::new().deleted(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        deleted.ids,
        vec![deleted_uid],
        "DELETED should match only the message marked \\\\Deleted"
    );

    let undeleted = conn
        .uid_search(SearchCriteria::new().undeleted(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        undeleted.ids,
        vec![undeleted_uid],
        "UNDELETED should match only the message without \\\\Deleted"
    );

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

/// UID SEARCH DRAFT and UNDRAFT track the `\\Draft` flag per RFC 3501 Section
/// 6.4.4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_draft_and_undraft_follow_draft_flag() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-draft");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (draft_uid, undraft_uid) = append_search_flag_messages(&conn, &mbox).await;

    conn.uid_store(
        &SequenceSet::new(draft_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Draft],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let draft = conn
        .uid_search(SearchCriteria::new().draft(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        draft.ids,
        vec![draft_uid],
        "DRAFT should match only the message marked \\\\Draft"
    );

    let undraft = conn
        .uid_search(SearchCriteria::new().undraft(), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        undraft.ids,
        vec![undraft_uid],
        "UNDRAFT should match only the message without \\\\Draft"
    );

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

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

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

    let uids = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert!(
        uids.ids.is_empty(),
        "SEARCH ALL on empty mailbox should be empty"
    );

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

// =========================================================================
// 11. LIST edge cases (RFC 3501 Section 6.3.8)
// =========================================================================

/// LIST with "%" pattern — top-level mailboxes only.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_percent_pattern() {
    let conn = connect_dovecot().await;

    let mailboxes = conn.list("", "%", TIMEOUT).await.unwrap();
    assert!(!mailboxes.is_empty(), "LIST % should return at least INBOX");
    assert!(
        mailboxes
            .iter()
            .any(|m| m.name.as_str().eq_ignore_ascii_case("INBOX")),
        "LIST % should include INBOX"
    );

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

/// LIST with "" reference and specific mailbox name.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_specific_mailbox() {
    let conn = connect_dovecot().await;

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

    let mailboxes = conn.list("", mbox, TIMEOUT).await.unwrap();
    assert_eq!(
        mailboxes.len(),
        1,
        "LIST with exact name should return 1 result"
    );
    assert_eq!(mailboxes[0].name.as_str(), mbox.as_str());

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

/// LIST returns hierarchy delimiter.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_hierarchy_delimiter() {
    let conn = connect_dovecot().await;

    // LIST "" "" returns the hierarchy delimiter per RFC 3501 Section 6.3.8.
    let mailboxes = conn.list("", "", TIMEOUT).await.unwrap();
    // Should return at least one entry with a delimiter.
    if !mailboxes.is_empty() {
        // Dovecot typically uses '.' or '/' as delimiter.
        let delim = mailboxes[0].delimiter;
        assert!(delim.is_some(), "hierarchy delimiter should be present");
    }

    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
/// (RFC 3501 Section 6.3.8).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_reference_resolves_relative_mailbox_name() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-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 "%" and a non-empty reference name must match only the next
/// hierarchy level below that reference (RFC 3501 Section 6.3.8).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-list-reference-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();
}

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

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-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();
}

/// LIST-EXTENDED with multiple relative patterns must resolve each pattern
/// against the reference name and return canonical mailbox names
/// (RFC 5258 Section 3, RFC 3501 Section 6.3.8).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_reference_resolves_multiple_patterns() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-list-extended-reference-parent");
    let child_one = format!("{parent}{delimiter}child-one");
    let child_two = format!("{parent}{delimiter}child-two");
    let reference = format!("{parent}{delimiter}");

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

    let listing = conn
        .list_extended(&reference, &["child-one", "child-two"], &[], &[], 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_one.clone(), child_two.clone()],
        "LIST-EXTENDED should resolve every relative pattern against the reference: {listing:?}"
    );

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

/// LIST-STATUS must resolve a relative mailbox name against the reference name
/// and correlate the returned STATUS data with that mailbox
/// (RFC 5819 Section 2, RFC 3501 Section 6.3.8).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_reference_resolves_relative_mailbox_name() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-list-status-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.append(&child, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&child, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let results = conn
        .list_status(&reference, "child", "MESSAGES UIDNEXT", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        results.len(),
        1,
        "LIST-STATUS with a relative mailbox name should resolve to one canonical match: {results:?}"
    );

    let (info, items) = &results[0];
    assert_eq!(info.name.as_str(), child.as_str());

    let messages = items.iter().find_map(|item| match item {
        daaki_imap::types::StatusItem::Messages(count) => Some(*count),
        _ => None,
    });
    assert_eq!(
        messages,
        Some(2),
        "expected two appended messages: {items:?}"
    );

    let uid_next = items.iter().find_map(|item| match item {
        daaki_imap::types::StatusItem::UidNext(next) => Some(*next),
        _ => None,
    });
    assert!(
        uid_next.is_some_and(|next| next >= 3),
        "UIDNEXT should advance past the appended messages: {items:?}"
    );

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

/// LIST-EXTENDED with `RETURN (CHILDREN)` and `%` under a non-empty
/// reference must match only the direct child mailboxes below that reference
/// (RFC 5258 Section 3, RFC 3501 Section 6.3.8, RFC 3348).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-list-extended-reference-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_extended(&reference, &["%"], &[], &["CHILDREN"], 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-EXTENDED % under a reference must return only direct children: {listing:?}"
    );
    assert!(
        listing[0]
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::HasChildren)),
        "RETURN (CHILDREN) should still surface child hierarchy metadata: {:?}",
        listing[0].attributes
    );

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

/// LIST-STATUS with `%` and a non-empty reference must report only the direct
/// child mailboxes below that reference and correlate STATUS data per mailbox
/// (RFC 5819 Section 2, RFC 3501 Section 6.3.8).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-list-status-reference-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.append(&child, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&grandchild, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&grandchild, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let results = conn
        .list_status(&reference, "%", "MESSAGES UIDNEXT", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        results.len(),
        1,
        "LIST-STATUS % under a reference must return only direct children: {results:?}"
    );
    assert_eq!(results[0].0.name.as_str(), child.as_str());
    assert_eq!(
        status_messages(&results[0].1),
        Some(1),
        "LIST-STATUS must correlate the direct child mailbox with its own STATUS data: {:?}",
        results[0].1
    );
    assert!(
        status_uidnext(&results[0].1).is_some_and(|uidnext| uidnext >= 2),
        "UIDNEXT should reflect the one appended child message: {:?}",
        results[0].1
    );

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

// =========================================================================
// 12. SELECT edge cases
// =========================================================================

/// SELECT after SELECT should auto-close the previous mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_after_select() {
    let conn = connect_dovecot().await;

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

    // Append a message to mbox1.
    conn.select(mbox1, TIMEOUT).await.unwrap();
    conn.append(mbox1, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // SELECT mbox2 should implicitly close mbox1.
    let selected = conn.select(mbox2, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 0, "mbox2 should be empty");
    assert_eq!(conn.session_state(), SessionState::Selected);

    // Go back to mbox1 — message should still be there.
    let selected = conn.select(mbox1, TIMEOUT).await.unwrap();
    assert_eq!(selected.exists, 1, "mbox1 should still have its message");

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

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

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

    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    // UID_NEXT should be >= 1 per RFC 3501.
    assert!(
        selected.uid_next.unwrap_or(0) >= 1,
        "UIDNEXT should be >= 1"
    );

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

/// SELECT returns permanent flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_permanent_flags() {
    let conn = connect_dovecot().await;

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

    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    // Dovecot should return permanent flags.
    assert!(
        !selected.permanent_flags.is_empty(),
        "permanent flags should not be empty"
    );

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

/// SELECT returns flags list.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_flags_list() {
    let conn = connect_dovecot().await;

    let selected = conn.select("INBOX", TIMEOUT).await.unwrap();
    // FLAGS response should include system flags.
    assert!(!selected.flags.is_empty(), "FLAGS should not be empty");
    // At minimum, standard flags should be present.
    assert!(
        selected.flags.contains(&Flag::Seen),
        "FLAGS should include \\Seen"
    );
    assert!(
        selected.flags.contains(&Flag::Deleted),
        "FLAGS should include \\Deleted"
    );

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

/// EXAMINE returns read_only = true.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_examine_is_read_only() {
    let conn = connect_dovecot().await;

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

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

// =========================================================================
// 13. EXAMINE prevents writes (RFC 3501 Section 6.3.2)
// =========================================================================

/// STORE on an EXAMINEd mailbox should fail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_examine_store_fails() {
    let conn = connect_dovecot().await;

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

    // Append via SELECT, then close and re-open via EXAMINE.
    conn.select(mbox, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let fetches = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();
    let uid = fetches[0].uid.unwrap();
    conn.close(TIMEOUT).await.unwrap();

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

    let result = conn
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            None,
            TIMEOUT,
        )
        .await;
    // Dovecot should reject STORE on an EXAMINEd mailbox.
    // Some servers return Ok but silently ignore; others return NO.
    // Either way, the flag should NOT be persisted.
    drop(result);

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

// =========================================================================
// 14. STATUS edge cases (RFC 3501 Section 6.3.10)
// =========================================================================

/// STATUS with all known items.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_all_items() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-status-all-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.unselect(TIMEOUT).await.unwrap();

    let items = conn
        .status(
            mbox,
            "(MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN)",
            TIMEOUT,
        )
        .await
        .unwrap()
        .items;

    // Verify we got multiple status items.
    assert!(
        items.len() >= 3,
        "expected at least 3 STATUS items, got {items:?}"
    );

    // Messages should be >= 1.
    let has_msgs = items
        .iter()
        .any(|i| matches!(i, daaki_imap::types::StatusItem::Messages(n) if *n >= 1));
    assert!(has_msgs, "expected MESSAGES >= 1: {items:?}");

    // UIDVALIDITY must be > 0.
    let has_uidv = items
        .iter()
        .any(|i| matches!(i, daaki_imap::types::StatusItem::UidValidity(n) if *n > 0));
    assert!(has_uidv, "expected UIDVALIDITY > 0: {items:?}");

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

/// STATUS on the currently selected mailbox (should still work).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_status_on_selected_mailbox() {
    let conn = connect_dovecot().await;

    // STATUS on a different mailbox while INBOX is selected.
    conn.select("INBOX", TIMEOUT).await.unwrap();

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

    let items = conn
        .status(mbox, "(MESSAGES)", TIMEOUT)
        .await
        .unwrap()
        .items;
    assert!(
        !items.is_empty(),
        "STATUS on another mailbox while selected should work"
    );

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

// =========================================================================
// 15. RENAME edge cases
// =========================================================================

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

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

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

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

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

    // New name should have both messages.
    let selected = conn.select(new_name, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 2,
        "renamed mailbox should preserve message count"
    );

    // Verify flags are preserved.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2);

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

/// RENAME must rename inferior hierarchical names along with the parent.
///
/// RFC 3501 Section 6.3.5: "If the name has inferior hierarchical names,
/// then the inferior hierarchical names MUST also be renamed."
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_parent_renames_inferiors() {
    let conn = connect_dovecot().await;

    let old_parent = unique_name("test-rename-tree-old");
    let old_child = format!("{old_parent}/child");
    let new_parent = unique_name("test-rename-tree-new");
    let new_child = format!("{new_parent}/child");

    conn.create(&old_parent, TIMEOUT).await.unwrap();
    conn.create(&old_child, TIMEOUT).await.unwrap();

    conn.rename(&old_parent, &new_parent, TIMEOUT)
        .await
        .unwrap();

    let mailboxes = conn.list("", "*", TIMEOUT).await.unwrap();
    assert!(
        mailboxes.iter().any(|m| m.name.as_str() == new_parent),
        "renamed parent mailbox must exist"
    );
    assert!(
        mailboxes.iter().any(|m| m.name.as_str() == new_child),
        "inferior mailbox must be renamed together with its parent"
    );
    assert!(
        !mailboxes.iter().any(|m| m.name.as_str() == old_parent),
        "old parent mailbox name must not remain after rename"
    );
    assert!(
        !mailboxes.iter().any(|m| m.name.as_str() == old_child),
        "old inferior mailbox name must not remain after rename"
    );

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

/// Renaming INBOX must move its messages into the new mailbox and leave INBOX
/// empty.
///
/// RFC 3501 Section 6.3.5: "Renaming INBOX is permitted, and has special
/// behavior. It moves all messages in INBOX to a new mailbox with the given
/// name, leaving INBOX empty."
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_inbox_moves_messages_and_leaves_inbox_empty() {
    let user = unique_name("test-rename-inbox-user");
    let conn = connect_dovecot_as(&user).await;
    let archived = unique_name("test-rename-inbox-archive");

    let inbox_before = conn.select("INBOX", TIMEOUT).await.unwrap();
    assert_eq!(
        inbox_before.exists, 0,
        "fresh Dovecot test user should start with an empty INBOX"
    );
    conn.close(TIMEOUT).await.unwrap();

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

    let archived_selected = conn.select(&archived, TIMEOUT).await.unwrap();
    assert_eq!(
        archived_selected.exists, 1,
        "renamed mailbox must receive the former INBOX contents"
    );
    conn.close(TIMEOUT).await.unwrap();

    let inbox_after = conn.select("INBOX", TIMEOUT).await.unwrap();
    assert_eq!(
        inbox_after.exists, 0,
        "INBOX must remain present but empty after renaming it"
    );
    conn.close(TIMEOUT).await.unwrap();

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

// =========================================================================
// 16. COPY edge cases
// =========================================================================

/// COPY a range of messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_uid_range() {
    let conn = connect_dovecot().await;

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

    for _ in 0..5 {
        conn.append(src, &[], None, TEST_MSG, TIMEOUT)
            .await
            .unwrap();
    }

    // Get all UIDs.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 5);
    let uid1 = fetches[0].uid.unwrap();
    let uid3 = fetches[2].uid.unwrap();

    // Copy a range (first 3 messages).
    conn.uid_copy(
        &SequenceSet::new(format!("{uid1}:{uid3}")).unwrap(),
        dst,
        TIMEOUT,
    )
    .await
    .unwrap();

    let selected = conn.select(dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 3,
        "destination should have 3 copied messages"
    );

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

/// UID COPY to the selected mailbox itself must append a duplicate message to
/// the end of that mailbox and return a distinct destination UID.
///
/// RFC 3501 Section 6.4.7: COPY "copies the specified message(s) to the end
/// of the specified destination mailbox." RFC 4315 Section 3: a UIDPLUS
/// server SHOULD return `COPYUID` for UID COPY.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_copy_to_same_mailbox_appends_duplicate_at_end() {
    let conn = connect_dovecot().await;

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

    let (_, source_uid) = conn
        .append(&mailbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap()
        .expect("Dovecot should return APPENDUID for APPEND");

    let copy_result = conn
        .uid_copy(
            &SequenceSet::new(source_uid.to_string()).unwrap(),
            &mailbox,
            TIMEOUT,
        )
        .await
        .unwrap();

    let copied_uid = match copy_result.code {
        Some(ResponseCode::CopyUid {
            source_uids,
            dest_uids,
            ..
        }) => {
            let copied_from = single_uid_from_ranges(&source_uids);
            let copied_to = single_uid_from_ranges(&dest_uids);
            assert_eq!(
                copied_from, source_uid,
                "COPYUID source set must identify the copied message"
            );
            assert!(
                copied_to > source_uid,
                "COPY to the same mailbox must assign a fresh UID at the end"
            );
            copied_to
        }
        other => panic!("expected COPYUID response code for same-mailbox UID COPY, got {other:?}"),
    };

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(format!("{source_uid}:{copied_uid}")).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        2,
        "mailbox must contain original plus copied message"
    );
    assert_eq!(fetches[0].uid, Some(source_uid));
    assert_eq!(fetches[1].uid, Some(copied_uid));
    for fetch in &fetches {
        assert!(
            fetch
                .flags
                .as_ref()
                .expect("FETCH FLAGS requested by the test must be present")
                .contains(&Flag::Seen),
            "COPY to the same mailbox must preserve flags"
        );
    }

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

/// COPY by sequence number to the selected mailbox itself must append a
/// duplicate to the end of that mailbox and assign a fresh destination UID.
///
/// RFC 3501 Section 6.4.7: COPY appends copied messages to the end of the
/// destination mailbox. RFC 4315 Section 3: a UIDPLUS server SHOULD return
/// `COPYUID` for COPY.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_by_sequence_to_same_mailbox_appends_duplicate_at_end() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-copy-seq-same-mailbox");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.append(&mailbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let before_copy = conn
        .uid_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");

    let copy_result = 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,
        "mailbox must contain original plus copied message"
    );
    assert_eq!(fetches[0].seq, 1);
    assert_eq!(fetches[1].seq, 2);
    assert_eq!(fetches[0].uid, Some(source_uid));
    let copied_uid = fetches[1].uid.expect("copied message must have UID");
    assert!(
        copied_uid > source_uid,
        "COPY to the same mailbox must assign a fresh UID at the end"
    );
    // RFC 4315 Section 3 says UIDPLUS servers SHOULD return COPYUID, with
    // exceptions such as UIDNOTSTICKY destinations, so validate it when present.
    if let Some(ResponseCode::CopyUid {
        source_uids,
        dest_uids,
        ..
    }) = copy_result.code
    {
        assert_eq!(
            single_uid_from_ranges(&source_uids),
            source_uid,
            "COPYUID source set must identify the copied message"
        );
        assert_eq!(
            single_uid_from_ranges(&dest_uids),
            copied_uid,
            "COPYUID destination set must identify the appended duplicate"
        );
    }
    for fetch in &fetches {
        assert!(
            fetch
                .flags
                .as_ref()
                .expect("FETCH FLAGS requested by the test must be present")
                .contains(&Flag::Seen),
            "COPY to the same mailbox must preserve flags"
        );
    }

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

/// COPY by sequence number should preserve message flags in the destination
/// mailbox.
///
/// RFC 3501 Section 6.4.7: COPY copies messages to the destination mailbox,
/// and the flags and internal date of the message(s) SHOULD be preserved.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_by_sequence_preserves_flags() {
    let conn = connect_dovecot().await;

    let src = unique_name("test-copy-seq-flags-src");
    let dst = unique_name("test-copy-seq-flags-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();

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

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

    conn.select(&dst, TIMEOUT).await.unwrap();
    let dst_fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(dst_fetches.len(), 1, "expected 1 message in destination");

    let dst_flags = dst_fetches[0]
        .flags
        .as_ref()
        .expect("FLAGS should be present in the destination fetch");
    assert!(
        dst_flags.contains(&Flag::Seen),
        "copied message should preserve \\Seen"
    );
    assert!(
        dst_flags.contains(&Flag::Flagged),
        "copied message should preserve \\Flagged"
    );

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

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

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

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

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

    let result = conn
        .uid_move_messages(
            &SequenceSet::new(uid.to_string()).unwrap(),
            "nonexistent-move-dst-999",
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::No { .. })),
        "MOVE to nonexistent mailbox should return No, got {result:?}"
    );

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

// =========================================================================
// 17. DELETE edge cases
// =========================================================================

/// DELETE mailbox that has messages inside it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_mailbox_with_messages() {
    let conn = connect_dovecot().await;

    let mbox = &format!("test-del-msgs-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();
    conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();

    // Dovecot allows deleting a non-empty mailbox.
    conn.delete(mbox, TIMEOUT).await.unwrap();

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

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

/// DELETE on a selectable mailbox with inferiors must preserve the inferior
/// mailboxes and turn the deleted parent into `\\Noselect`.
///
/// RFC 3501 Section 6.3.4: deleting a mailbox with inferior hierarchical
/// names is permitted when the mailbox is selectable, and "the name will
/// acquire the \\Noselect mailbox name attribute."
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_parent_mailbox_with_child_marks_parent_noselect() {
    let conn = connect_dovecot().await;

    let parent = unique_name("test-delete-parent");
    let child = format!("{parent}/child");

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

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

    let mailboxes = conn.list("", "*", TIMEOUT).await.unwrap();
    let parent_info = mailboxes
        .iter()
        .find(|m| m.name.as_str() == parent)
        .unwrap_or_else(|| panic!("deleted parent mailbox {parent:?} must still be listed"));
    assert!(
        parent_info
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::NoSelect)),
        "deleted parent mailbox with inferiors must become \\Noselect"
    );
    assert!(
        mailboxes.iter().any(|m| m.name.as_str() == child),
        "inferior mailbox must survive deletion of the selectable parent"
    );

    let selected_child = conn.select(&child, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_child.exists, 0,
        "inferior mailbox must remain selectable after deleting the parent"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&child, TIMEOUT).await.unwrap();
    if conn
        .list("", &parent, TIMEOUT)
        .await
        .unwrap()
        .iter()
        .any(|m| m.name.as_str() == parent)
    {
        conn.delete(&parent, TIMEOUT).await.unwrap();
    }
    conn.logout().await.unwrap();
}

/// SUBSCRIBE is valid in the selected state and makes the mailbox appear
/// in LSUB results (RFC 3501 Section 6.3.6, RFC 3501 Section 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 mbox = &format!("test-subscribe-selected-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

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

    let subscribed = conn.lsub("", mbox, TIMEOUT).await.unwrap();
    assert!(
        subscribed.iter().any(|entry| entry.name.as_str() == mbox),
        "LSUB should include mailbox subscribed while selected"
    );

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

/// A subscribed mailbox name must remain in LSUB even after the mailbox is
/// deleted.
///
/// RFC 3501 Section 6.3.6: the server "MUST NOT unilaterally remove an
/// existing mailbox name from the subscription list even if a mailbox by that
/// name no longer exists." RFC 3501 Section 6.3.9 exposes that list via LSUB.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_lsub_retains_deleted_subscribed_mailbox() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-lsub-deleted");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.subscribe(&mailbox, TIMEOUT).await.unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();

    let subscribed = conn.lsub("", "*", TIMEOUT).await.unwrap();
    assert!(
        subscribed.iter().any(|m| m.name.as_str() == mailbox),
        "deleted subscribed mailbox must remain visible in LSUB"
    );

    let _ = conn.unsubscribe(&mailbox, TIMEOUT).await;
    conn.logout().await.unwrap();
}

/// UNSUBSCRIBE is valid in the selected state and removes the mailbox from
/// LSUB results (RFC 3501 Section 6.3.7, RFC 3501 Section 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 mbox = &format!("test-unsubscribe-selected-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();
    conn.subscribe(mbox, TIMEOUT).await.unwrap();
    conn.select(mbox, TIMEOUT).await.unwrap();

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

    let subscribed = conn.lsub("", mbox, TIMEOUT).await.unwrap();
    assert!(
        !subscribed.iter().any(|entry| entry.name.as_str() == mbox),
        "LSUB should omit mailbox unsubscribed while selected"
    );

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

// =========================================================================
// 18. CONDSTORE edge cases (RFC 7162)
// =========================================================================

/// CONDSTORE: STORE with stale UNCHANGEDSINCE — should fail with MODIFIED.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_condstore_stale_unchangedsince() {
    let conn = connect_dovecot().await;

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

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

    // Get initial MODSEQ.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags, FetchAttr::ModSeq],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uid = fetches[0].uid.unwrap();
    let old_modseq = fetches[0].mod_seq.unwrap();

    // First STORE to advance the MODSEQ.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Seen],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Now try STORE with the OLD MODSEQ — should either:
    // 1. Return MODIFIED response code (the flag change is rejected), or
    // 2. Succeed if the server considers it a no-op (some implementations)
    let result = conn
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            Some(old_modseq),
            TIMEOUT,
        )
        .await;

    // Either Ok (some servers are lenient) or error is acceptable.
    // The key thing: no panic, no protocol error.
    match result {
        // Some servers accept if the flags haven't changed since; others reject.
        Ok(_) | Err(daaki_imap::Error::No { .. }) => {}
        Err(e) => panic!("unexpected error from stale UNCHANGEDSINCE: {e:?}"),
    }

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

// =========================================================================
// 19. BODYSTRUCTURE edge cases
// =========================================================================

/// Nested multipart: multipart/mixed containing multipart/alternative.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_bodystructure_nested_multipart() {
    let conn = connect_dovecot().await;

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

    let nested_msg = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Nested multipart\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <nested-mp@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"outer\"\r\n\
\r\n\
--outer\r\n\
Content-Type: multipart/alternative; boundary=\"inner\"\r\n\
\r\n\
--inner\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Plain text version.\r\n\
--inner\r\n\
Content-Type: text/html; charset=utf-8\r\n\
\r\n\
<p>HTML version</p>\r\n\
--inner--\r\n\
--outer\r\n\
Content-Type: application/pdf\r\n\
Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
JVBERi0xLjQKJcOkw7zDtsOfCg==\r\n\
--outer--\r\n";

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

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

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

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

            // First part: multipart/alternative.
            match &bodies[0] {
                daaki_imap::types::BodyStructure::Multipart {
                    media_subtype,
                    bodies: inner_bodies,
                    ..
                } => {
                    assert_eq!(media_subtype.to_ascii_uppercase(), "ALTERNATIVE");
                    assert_eq!(inner_bodies.len(), 2, "expected 2 alternative parts");

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

                    // text/html
                    match &inner_bodies[1] {
                        daaki_imap::types::BodyStructure::Text { media_subtype, .. } => {
                            assert_eq!(media_subtype.to_ascii_uppercase(), "HTML");
                        }
                        other => panic!("expected text/html, got {other:?}"),
                    }
                }
                other => panic!("expected nested multipart/alternative, got {other:?}"),
            }

            // Second part: application/pdf.
            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(), "PDF");
                }
                other => panic!("expected application/pdf, got {other:?}"),
            }
        }
        other => panic!("expected top-level multipart/mixed, got {other:?}"),
    }

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

/// BODYSTRUCTURE for a message/rfc822 encapsulated message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_bodystructure_message_rfc822() {
    let conn = connect_dovecot().await;

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

    let rfc822_msg = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Forwarded message\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <msg-rfc822@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: message/rfc822\r\n\
\r\n\
From: original@example.com\r\n\
To: sender@example.com\r\n\
Subject: Original message\r\n\
Date: Sat, 14 Mar 2026 10:00:00 +0000\r\n\
Message-ID: <original@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
This is the original message body.\r\n";

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

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

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

    // Should be a Message variant with an envelope of the encapsulated message.
    match bs {
        daaki_imap::types::BodyStructure::Message { envelope, body, .. } => {
            // The encapsulated envelope should have the original subject.
            assert_eq!(
                envelope.subject.as_deref(),
                Some("Original message"),
                "encapsulated envelope should have the original subject"
            );
            // The body of the encapsulated message should be text/plain.
            match body.as_ref() {
                daaki_imap::types::BodyStructure::Text { media_subtype, .. } => {
                    assert_eq!(media_subtype.to_ascii_uppercase(), "PLAIN");
                }
                other => {
                    // Some servers may wrap it differently; just verify no panic.
                    let _ = other;
                }
            }
        }
        _other => {
            // Some servers may represent this differently — just verify we parsed it.
            // The key thing is no parse error / panic.
        }
    }

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

// =========================================================================
// 20. ENABLE UTF8=ACCEPT (RFC 6855)
// =========================================================================

/// ENABLE UTF8=ACCEPT — verify the method works (may or may not be supported).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_enable_utf8() {
    let conn = connect_dovecot().await;

    let accepted = conn.enable_utf8(TIMEOUT).await.unwrap();
    // Result depends on server config. Either true or false is valid.
    let _ = accepted;

    // Connection should still work regardless.
    conn.noop(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// =========================================================================
// 21. IDLE timeout (RFC 2177)
// =========================================================================

/// IDLE must surface the first server event seen during the wait window, or
/// return `Timeout` if the server stays silent.
///
/// RFC 2177 Section 3 allows the server to send any untagged response while
/// the client is idling.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_idle_short_wait_returns_first_server_event_or_timeout() {
    let conn = connect_dovecot().await;

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

    let cancel = tokio_util::sync::CancellationToken::new();
    // Use a very short timeout (2 seconds).
    let event = conn.idle(Duration::from_secs(2), cancel).await.unwrap();

    // After the fix, `* OK Still here` keepalives are filtered inside
    // `idle()`, so only Timeout should be returned within 2 seconds.
    assert_eq!(
        event,
        IdleEvent::Timeout,
        "expected Timeout from short IDLE"
    );

    // Connection should still be usable after timeout.
    conn.noop(TIMEOUT).await.unwrap();

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

// =========================================================================
// 22. Concurrent connections
// =========================================================================

/// Two connections to the same mailbox should not interfere.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_concurrent_connections() {
    let conn1 = connect_dovecot().await;
    let conn2 = connect_dovecot().await;

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

    conn1.select(mbox, TIMEOUT).await.unwrap();
    conn2.select(mbox, TIMEOUT).await.unwrap();

    // conn1 appends.
    conn1
        .append(mbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // conn2 can NOOP and see updated EXISTS.
    conn2.noop(TIMEOUT).await.unwrap();

    // conn2 appends.
    conn2
        .append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // Both connections see 2 messages after re-select.
    let sel1 = conn1.select(mbox, TIMEOUT).await.unwrap();
    let sel2 = conn2.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(sel1.exists, 2);
    assert_eq!(sel2.exists, 2);

    conn1.logout().await.unwrap();
    conn2.logout().await.unwrap();
}

// =========================================================================
// 23. ENVELOPE edge cases
// =========================================================================

/// ENVELOPE with multiple recipients (To, Cc, Bcc).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_envelope_multiple_recipients() {
    let conn = connect_dovecot().await;

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

    let msg = b"\
From: sender@example.com\r\n\
To: alice@example.com, bob@example.com\r\n\
Cc: carol@example.com\r\n\
Bcc: dave@example.com\r\n\
Subject: Multiple recipients\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <multi-rcpt@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Test body.\r\n";

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Envelope],
            TIMEOUT,
        )
        .await
        .unwrap();
    let env = fetches[0].envelope.as_ref().expect("expected ENVELOPE");

    // To should have 2 addresses.
    assert_eq!(env.to.len(), 2, "expected 2 To addresses: {:?}", env.to);

    // Cc should have 1 address.
    assert_eq!(env.cc.len(), 1, "expected 1 Cc address: {:?}", env.cc);

    // From should have 1 address.
    assert_eq!(env.from.len(), 1, "expected 1 From address: {:?}", env.from);

    // Verify email addresses.
    let to_emails: Vec<String> = env
        .to
        .iter()
        .filter_map(daaki_imap::types::EnvelopeAddress::email)
        .collect();
    assert!(to_emails.contains(&"alice@example.com".to_string()));
    assert!(to_emails.contains(&"bob@example.com".to_string()));

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

/// ENVELOPE with NIL fields (no subject, no date).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_envelope_minimal_headers() {
    let conn = connect_dovecot().await;

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

    // A message with minimal headers — many fields will be NIL.
    let msg = b"\
From: sender@example.com\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
\r\n\
Body only, no subject or To header.\r\n";

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Envelope],
            TIMEOUT,
        )
        .await
        .unwrap();
    let env = fetches[0].envelope.as_ref().expect("expected ENVELOPE");

    // Subject should be None (NIL).
    assert!(
        env.subject.is_none(),
        "subject should be None for message without Subject header"
    );

    // To should be empty.
    assert!(
        env.to.is_empty(),
        "To should be empty for message without To header"
    );

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

/// ENVELOPE with In-Reply-To and References headers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_envelope_reply_to_references() {
    let conn = connect_dovecot().await;

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

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

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Envelope],
            TIMEOUT,
        )
        .await
        .unwrap();
    let env = fetches[0].envelope.as_ref().expect("expected ENVELOPE");

    assert!(env.in_reply_to.is_some(), "In-Reply-To should be present");
    assert!(env.message_id.is_some(), "Message-ID should be present");
    assert_eq!(
        env.first_in_reply_to(),
        Some("original-1@daaki"),
        "first_in_reply_to should return the referenced message ID"
    );

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

// =========================================================================
// 24. QRESYNC with known_uids (RFC 7162 Section 3.2.5.2)
// =========================================================================

/// SELECT QRESYNC with known_uids set.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_qresync_with_known_uids() {
    let conn = connect_dovecot().await;

    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-known-{}", std::process::id());
    conn.create(mbox, TIMEOUT).await.unwrap();

    // Append several messages.
    for _ in 0..3 {
        conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
            .await
            .unwrap();
    }

    // First SELECT.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    let uid_validity = selected.uid_validity.unwrap();
    let mod_seq = selected.highest_mod_seq.unwrap();

    // Get UIDs.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let uids: Vec<u32> = fetches.iter().filter_map(|f| f.uid).collect();
    assert_eq!(uids.len(), 3);

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

    // Re-select with QRESYNC and known_uids.
    let uid_range = format!("{}:{}", uids[0], uids[2]);
    let mut params = daaki_imap::types::QresyncParams::new(uid_validity, mod_seq);
    params.known_uids = Some(uid_range);

    let qresync = conn
        .select_with(
            mbox,
            &daaki_imap::types::SelectOptions::qresync(params),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(qresync.exists, 3);
    assert!(qresync.highest_mod_seq.is_some());

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

// =========================================================================
// 25. Rapid-fire operations — stress test
// =========================================================================

/// Rapid APPEND + FETCH + STORE + EXPUNGE cycle.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rapid_operations() {
    let conn = connect_dovecot().await;

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

    // Rapidly append, flag, and expunge 10 messages.
    for i in 0..10 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Rapid test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <rapid-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Rapid message {i}.\r\n"
        );
        conn.append(mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    // Re-select to refresh.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 10,
        "expected 10 messages after rapid append"
    );

    // Fetch all.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::Envelope],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 10);

    // Flag all as \Seen.
    let all_uids: Vec<String> = fetches.iter().map(|f| f.uid.unwrap().to_string()).collect();
    let uid_set = all_uids.join(",");
    conn.uid_store(
        &SequenceSet::new(&uid_set).unwrap(),
        StoreOperation::Add,
        &[Flag::Seen],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Mark first 5 as \Deleted and expunge.
    let first_five = all_uids[..5].join(",");
    conn.uid_store(
        &SequenceSet::new(&first_five).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.expunge(TIMEOUT).await.unwrap();

    // Verify 5 remain.
    let selected = conn.select(mbox, TIMEOUT).await.unwrap();
    assert_eq!(
        selected.exists, 5,
        "expected 5 messages after expunging first 5"
    );

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

/// Multiple NOOP calls in sequence should all succeed.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multiple_noops() {
    let conn = connect_dovecot().await;

    for _ in 0..10 {
        conn.noop(TIMEOUT).await.unwrap();
    }

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

// =========================================================================
// 26. Error: FETCH without SELECT
// =========================================================================

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

    let result = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await;
    assert!(
        result.is_err(),
        "FETCH without SELECT should fail, got {result:?}"
    );

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

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

    let result = conn
        .uid_store(
            &SequenceSet::new("1").unwrap(),
            StoreOperation::Add,
            &[Flag::Seen],
            None,
            TIMEOUT,
        )
        .await;
    assert!(
        result.is_err(),
        "STORE without SELECT should fail, got {result:?}"
    );

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

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

    let result = conn
        .uid_copy(&SequenceSet::new("1").unwrap(), "INBOX", TIMEOUT)
        .await;
    assert!(
        result.is_err(),
        "COPY without SELECT should fail, got {result:?}"
    );

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

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

    let result = conn
        .uid_move_messages(&SequenceSet::new("1").unwrap(), "INBOX", TIMEOUT)
        .await;
    assert!(
        result.is_err(),
        "MOVE without SELECT should fail, got {result:?}"
    );

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

// =========================================================================
// 27. COMPRESS + complex operations (RFC 4978)
// =========================================================================

/// COMPRESSed connection operations must continue to work after a successful
/// upgrade per RFC 4978 Sections 3 and 4.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_compress_full_workflow() {
    let conn = connect_dovecot().await;

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

            // APPEND over compressed connection.
            conn.append(mbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
                .await
                .unwrap();
            conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
                .await
                .unwrap();

            // SEARCH over compressed connection.
            let uids = conn.uid_search("UNSEEN", TIMEOUT).await.unwrap();
            assert_eq!(uids.ids.len(), 1, "expected 1 unseen over compressed conn");

            // STORE over compressed connection.
            conn.uid_store(
                &SequenceSet::new(uids.ids[0].to_string()).unwrap(),
                StoreOperation::Add,
                &[Flag::Flagged],
                None,
                TIMEOUT,
            )
            .await
            .unwrap();

            // COPY over compressed connection.
            conn.uid_copy(
                &SequenceSet::new(uids.ids[0].to_string()).unwrap(),
                dest,
                TIMEOUT,
            )
            .await
            .unwrap();

            // EXPUNGE over compressed connection.
            conn.uid_store(
                &SequenceSet::new(uids.ids[0].to_string()).unwrap(),
                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, 1, "one message should remain");

            conn.close(TIMEOUT).await.unwrap();
            conn.delete(mbox, TIMEOUT).await.unwrap();
            conn.delete(dest, TIMEOUT).await.unwrap();
        }
        Err(daaki_imap::Error::MissingCapability(_)) => {
            // Not supported — skip.
        }
        Err(e) => panic!("unexpected COMPRESS error: {e:?}"),
    }

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

// =========================================================================
// 28. MULTIAPPEND edge cases (RFC 3502)
// =========================================================================

/// MULTIAPPEND with a single message (degenerate case).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_single_message() {
    let conn = connect_dovecot().await;

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

    let mut msg = daaki_imap::types::AppendMessage::new(TEST_MSG.to_vec());
    msg.flags = vec![Flag::Seen];
    let messages = vec![msg];

    let result = conn.multi_append(mbox, &messages, TIMEOUT).await.unwrap();
    assert_eq!(result.len(), 1, "single MULTIAPPEND should return 1 UID");
    assert!(result[0].0 > 0, "UIDVALIDITY should be > 0");

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

/// MULTIAPPEND with UIDPLUS must return one APPENDUID entry per appended
/// message, in append order, and each returned UID must be fetchable.
///
/// RFC 3502 Section 3 appends multiple messages in one APPEND command.
/// RFC 4315 Section 3 defines APPENDUID for appended messages on UIDPLUS
/// servers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_returns_appenduid_for_each_message() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-multiappend-appenduid");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let messages = (1..=3)
        .map(|i| {
            AppendMessage::new(format!(
                "From: sender@example.com\r\n\
                 To: recipient@example.com\r\n\
                 Subject: Multiappend APPENDUID {i}\r\n\
                 Date: Sun, 15 Mar 2026 00:00:0{i} +0000\r\n\
                 Message-ID: <multiappend-appenduid-{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"
            ))
        })
        .collect::<Vec<_>>();

    let appended = conn
        .multi_append(&mailbox, &messages, TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        appended.len(),
        messages.len(),
        "UIDPLUS server must return one APPENDUID tuple per appended message"
    );

    let uid_validity = appended[0].0;
    let mut append_uids = Vec::with_capacity(appended.len());
    for (index, (returned_uid_validity, uid)) in appended.iter().copied().enumerate() {
        assert_eq!(
            returned_uid_validity, uid_validity,
            "UIDVALIDITY must remain stable within the same mailbox"
        );
        if let Some(previous_uid) = append_uids.last().copied() {
            assert!(
                uid > previous_uid,
                "later MULTIAPPEND entries must receive larger UIDs"
            );
        }
        append_uids.push(uid);
        assert!(uid > 0, "returned UID #{index} must be positive");
    }

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

    let fetched_uids: Vec<u32> = fetches
        .iter()
        .map(|fetch| fetch.uid.expect("uid_fetch must return UIDs"))
        .collect();
    assert_eq!(
        fetched_uids, append_uids,
        "MULTIAPPEND APPENDUID values must match the messages stored in mailbox order"
    );

    let fetched_subjects: Vec<&str> = fetches
        .iter()
        .map(|fetch| {
            fetch
                .envelope
                .as_ref()
                .and_then(|envelope| envelope.subject.as_deref())
                .expect("multi-appended message must have an envelope subject")
        })
        .collect();
    assert_eq!(
        fetched_subjects,
        vec![
            "Multiappend APPENDUID 1",
            "Multiappend APPENDUID 2",
            "Multiappend APPENDUID 3",
        ],
        "messages must remain in append order after MULTIAPPEND"
    );

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

// =========================================================================
// 29. Post-logout / Post-close edge cases
// =========================================================================

/// APPEND after logout should fail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_after_logout() {
    let conn = connect_dovecot().await;
    conn.logout().await.unwrap();

    let result = conn.append("INBOX", &[], None, TEST_MSG, TIMEOUT).await;
    assert!(
        result.is_err(),
        "APPEND after logout should fail, got {result:?}"
    );
}

/// FETCH after logout should fail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_after_logout() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();

    let result = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await;
    assert!(
        result.is_err(),
        "FETCH after logout should fail, got {result:?}"
    );
}

/// CREATE after logout should fail.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_after_logout() {
    let conn = connect_dovecot().await;
    conn.logout().await.unwrap();

    let result = conn.create("should-not-exist", TIMEOUT).await;
    assert!(
        result.is_err(),
        "CREATE after logout should fail, got {result:?}"
    );
}

// =========================================================================
// 30. Login with empty username/password
// =========================================================================

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

    let result = conn.login("", "testpass", TIMEOUT).await;
    assert!(result.is_err(), "LOGIN with empty username should fail");
}

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

    let result = conn.login("testuser", "", TIMEOUT).await;
    assert!(result.is_err(), "LOGIN with empty password should fail");
}

// =========================================================================
// next_event — async event delivery (RFC 3501 Section 7 / Section 5.3)
// =========================================================================

/// `next_event()` must return `Ok(None)` when the timeout expires without
/// any server notification arriving.
///
/// RFC 3501 Section 5.3: servers may send untagged data at any time, but
/// a quiet connection should produce no events.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_next_event_timeout_returns_none() {
    let conn = connect_dovecot().await;
    conn.select("INBOX", TIMEOUT).await.unwrap();

    // Short timeout — no one is sending mail, so no events should arrive.
    let event = conn.next_event(Duration::from_millis(200)).await.unwrap();
    assert!(
        event.is_none(),
        "next_event on a quiet connection must return None on timeout; got {event:?}"
    );

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

/// `next_event()` must surface a queued EXISTS event after another session
/// appends to the currently selected mailbox and a NOOP flushes unsolicited
/// updates.
///
/// RFC 3501 Section 7.3.1: the server sends an EXISTS response when the size
/// of the mailbox changes. RFC 3501 Section 6.1.2: NOOP allows the server to
/// send any pending mailbox status updates immediately.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_next_event_receives_exists_after_external_append() {
    let watcher = connect_dovecot().await;
    let appender = connect_dovecot().await;

    let mbox = format!("test-next-event-exists-{}", std::process::id());
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher.select(&mbox, TIMEOUT).await.unwrap();
    let _ = watcher.drain_events().await;

    appender
        .append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // RFC 3501 Section 6.1.2: NOOP is the synchronization point for pending
    // EXISTS updates when the server does not push them on an otherwise quiet
    // connection.
    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
    let mut saw_exists = None;
    while tokio::time::Instant::now() < deadline {
        if let Some(TypedEvent::Exists(count)) = watcher
            .next_event(Duration::from_millis(250))
            .await
            .unwrap()
        {
            saw_exists = Some(count);
            break;
        }
    }

    let exists = saw_exists.expect("expected EXISTS event after external APPEND");
    assert_eq!(exists, 1, "mailbox must report one existing message");

    appender.logout().await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `next_event()` must surface a queued FETCH update after another session
/// changes flags in the currently selected mailbox and a NOOP flushes the
/// pending update.
///
/// RFC 3501 Section 7.4.2: servers send untagged FETCH responses to reflect
/// dynamic message attribute changes such as flag updates. RFC 3501 Section
/// 6.1.2 allows NOOP to trigger delivery of pending mailbox updates.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_next_event_receives_fetch_update_after_external_store() {
    let watcher = connect_dovecot().await;
    let modifier = connect_dovecot().await;

    let mbox = format!("test-next-event-fetch-{}", std::process::id());
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher.select(&mbox, TIMEOUT).await.unwrap();
    watcher
        .append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    modifier.select(&mbox, TIMEOUT).await.unwrap();
    let uid = modifier
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap()
        .pop()
        .and_then(|f| f.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 (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
    let mut saw_fetch = None;
    while tokio::time::Instant::now() < deadline {
        if let Some(TypedEvent::FetchUpdate(fetch)) = watcher
            .next_event(Duration::from_millis(250))
            .await
            .unwrap()
        {
            saw_fetch = Some(fetch);
            break;
        }
    }

    let fetch = saw_fetch.expect("expected FETCH update after external STORE");
    assert_eq!(fetch.seq, 1, "FETCH update must refer to sequence number 1");
    assert!(
        fetch
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Flagged)),
        "FETCH update must reflect the added \\Flagged flag, got {:?}",
        fetch.flags
    );

    modifier.close(TIMEOUT).await.unwrap();
    modifier.logout().await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

/// `next_event()` must surface an unsolicited EXPUNGE after another session
/// expunges a message from the currently selected mailbox and a NOOP flushes
/// pending updates.
///
/// RFC 3501 Section 7.4.1: servers send an untagged EXPUNGE response when a
/// message is permanently removed. RFC 3501 Section 6.1.2 permits NOOP to
/// deliver pending mailbox updates on an otherwise quiet connection.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_next_event_receives_expunge_after_external_expunge() {
    let watcher = connect_dovecot().await;
    let expunger = connect_dovecot().await;

    let mbox = unique_name("test-next-event-expunge");
    watcher.create(&mbox, TIMEOUT).await.unwrap();
    watcher.select(&mbox, TIMEOUT).await.unwrap();
    watcher
        .append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    expunger.select(&mbox, 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 (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
    let mut saw_expunge = None;
    while tokio::time::Instant::now() < deadline {
        if let Some(TypedEvent::Expunge(seq)) = watcher
            .next_event(Duration::from_millis(250))
            .await
            .unwrap()
        {
            saw_expunge = Some(seq);
            break;
        }
    }

    let seq = saw_expunge.expect("expected EXPUNGE event after external expunge");
    assert_eq!(seq, 1, "EXPUNGE must report the removed sequence number");

    let remaining = watcher.search("ALL", TIMEOUT).await.unwrap();
    assert!(
        remaining.ids.is_empty(),
        "mailbox must be empty after the external expunge"
    );

    expunger.close(TIMEOUT).await.unwrap();
    expunger.logout().await.unwrap();
    watcher.close(TIMEOUT).await.unwrap();
    watcher.delete(&mbox, TIMEOUT).await.unwrap();
    watcher.logout().await.unwrap();
}

// =========================================================================
// fetch_streaming — sequence-number streaming (RFC 3501 Section 6.4.5)
// =========================================================================

/// `fetch_streaming` must deliver each FETCH response via the provided
/// channel as it arrives, using sequence numbers rather than UIDs.
///
/// RFC 3501 Section 6.4.5: FETCH retrieves data associated with a
/// message in the mailbox. This tests the sequence-number variant of
/// the streaming API.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_streaming_by_sequence() {
    let conn = connect_dovecot().await;

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

    // Append 3 messages.
    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Stream seq test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <stream-seq-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Message body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    // Re-select to see the new messages.
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Fetch all 3 via streaming.
    let seq = SequenceSet::new("1:3").unwrap();
    let (tx, mut rx) = tokio::sync::mpsc::channel(16);

    conn.fetch_streaming(&seq, &[FetchAttr::Uid, FetchAttr::Flags], tx, TIMEOUT)
        .await
        .unwrap();

    let mut responses = Vec::new();
    while let Some(result) = rx.recv().await {
        responses.push(result.unwrap());
    }

    assert_eq!(
        responses.len(),
        3,
        "must receive 3 FETCH responses via streaming"
    );

    // Each response must carry the requested attributes.
    for (i, resp) in responses.iter().enumerate() {
        assert!(
            resp.uid.is_some(),
            "streamed response {} must have a UID",
            i + 1
        );
        assert!(
            resp.flags.is_some(),
            "streamed response {} must have FLAGS",
            i + 1
        );
    }

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

/// `fetch_streaming` must only deliver the requested sequence-number subset.
///
/// RFC 3501 Section 6.4.5: `FETCH` operates on the specified message sequence
/// set, so a live server must not stream responses for messages outside the
/// requested subset.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_streaming_sequence_subset_only_returns_requested_messages() {
    let conn = connect_dovecot().await;

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

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Stream subset {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <stream-subset-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Stream subset body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    conn.fetch_streaming(
        &SequenceSet::new("2:3").unwrap(),
        &[FetchAttr::Uid, FetchAttr::Flags],
        tx,
        TIMEOUT,
    )
    .await
    .unwrap();

    let mut responses = Vec::new();
    while let Some(result) = rx.recv().await {
        responses.push(result.unwrap());
    }

    let mut seqs = responses.iter().map(|resp| resp.seq).collect::<Vec<_>>();
    seqs.sort_unstable();
    assert_eq!(
        seqs,
        vec![2, 3],
        "streamed FETCH responses must match the requested sequence subset"
    );

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

/// `fetch_streaming` against an empty mailbox must be rejected when the
/// request uses any non-zero message sequence number.
///
/// RFC 3501 Section 9 (`seq-number`): a server should send a tagged BAD
/// response to a command that uses a message sequence number greater than the
/// number of messages in the selected mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_streaming_empty_mailbox_rejects_nonzero_sequence_set() {
    let user = unique_name("fetch-stream-empty-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = unique_name("test-fetch-stream-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    let result = conn
        .fetch_streaming(
            &SequenceSet::new("999999").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            tx,
            TIMEOUT,
        )
        .await;

    assert!(
        matches!(&result, Err(daaki_imap::Error::Bad { .. })),
        "empty-mailbox FETCH with a non-zero sequence set must be rejected with BAD, got {result:?}"
    );

    let buffered = conn
        .fetch(
            &SequenceSet::new("999999").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(&buffered, Err(daaki_imap::Error::Bad { .. })),
        "regular FETCH must also reject an out-of-range sequence set in an empty mailbox, got {buffered:?}"
    );
    assert!(
        tokio::time::timeout(TIMEOUT, rx.recv())
            .await
            .expect("stream receiver must close promptly after FETCH rejects")
            .is_none(),
        "stream receiver must close without yielding FETCH data after BAD"
    );

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

/// `fetch_streaming` must reject an out-of-range message sequence number with
/// a tagged BAD response.
///
/// RFC 3501 Section 9 (`seq-number`): a command that uses a message sequence
/// number greater than the number of messages in the selected mailbox should
/// be rejected with BAD rather than treated like a nonexistent UID.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_streaming_out_of_range_sequence_returns_bad() {
    let user = unique_name("fetch-stream-missing-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = unique_name("test-fetch-stream-missing");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    let result = conn
        .fetch_streaming(
            &SequenceSet::new("999999").unwrap(),
            &[FetchAttr::Uid],
            tx,
            TIMEOUT,
        )
        .await;

    assert!(
        matches!(&result, Err(daaki_imap::Error::Bad { .. })),
        "out-of-range sequence-number FETCH must be rejected with BAD, got {result:?}"
    );

    let buffered = conn
        .fetch(
            &SequenceSet::new("999999").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(&buffered, Err(daaki_imap::Error::Bad { .. })),
        "regular FETCH must also reject an out-of-range message sequence number, got {buffered:?}"
    );
    assert!(
        tokio::time::timeout(TIMEOUT, rx.recv())
            .await
            .expect("stream receiver must close promptly after FETCH rejects")
            .is_none(),
        "stream receiver must close without yielding FETCH data after BAD"
    );

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

/// `fetch_streaming` must reject a mixed sequence set if any requested message
/// sequence number exceeds the mailbox size.
///
/// RFC 3501 Section 9 (`seq-number`) makes sequence numbers stricter than
/// UIDs: the server should reject a command that uses any message sequence
/// number greater than the number of messages in the selected mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_streaming_mixed_valid_and_out_of_range_sequences_returns_bad() {
    let user = unique_name("fetch-stream-mixed-user");
    let conn = connect_dovecot_as(&user).await;

    let mbox = unique_name("test-fetch-stream-mixed");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    let result = conn
        .fetch_streaming(
            &SequenceSet::new("1,999999").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            tx,
            TIMEOUT,
        )
        .await;

    assert!(
        matches!(&result, Err(daaki_imap::Error::Bad { .. })),
        "mixed valid/out-of-range sequence-number FETCH must be rejected with BAD, got {result:?}"
    );

    let buffered = conn
        .fetch(
            &SequenceSet::new("1,999999").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(&buffered, Err(daaki_imap::Error::Bad { .. })),
        "regular FETCH must also reject a mixed valid/out-of-range sequence set, got {buffered:?}"
    );
    assert!(
        tokio::time::timeout(TIMEOUT, rx.recv())
            .await
            .expect("stream receiver must close promptly after FETCH rejects")
            .is_none(),
        "stream receiver must close without yielding FETCH data after BAD"
    );

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

/// `uid_fetch_streaming` must only deliver the requested UID subset.
///
/// RFC 3501 Section 6.4.8 applies the UID command to `FETCH`, so the server
/// must constrain streamed responses to the requested UID set.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_streaming_uid_subset_only_returns_requested_messages() {
    let conn = connect_dovecot().await;

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

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: UID stream subset {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <uid-stream-subset-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             UID stream subset body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    let all_uids = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(
        all_uids.len(),
        3,
        "expected exactly three messages in the test mailbox"
    );

    let requested = vec![all_uids[0], all_uids[2]];
    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    conn.uid_fetch_streaming(
        &SequenceSet::new(format!("{},{}", requested[0], requested[1])).unwrap(),
        &[FetchAttr::Uid, FetchAttr::Flags],
        tx,
        TIMEOUT,
    )
    .await
    .unwrap();

    let mut responses = Vec::new();
    while let Some(result) = rx.recv().await {
        responses.push(result.unwrap());
    }

    let mut returned_uids = responses
        .iter()
        .map(|resp| resp.uid.expect("UID FETCH responses must include UID"))
        .collect::<Vec<_>>();
    returned_uids.sort_unstable();
    assert_eq!(
        returned_uids, requested,
        "streamed UID FETCH responses must match the requested UID subset"
    );

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

/// `LIST RETURN (CHILDREN)` must distinguish a parent mailbox from a leaf.
///
/// RFC 5258 Section 4 defines the `CHILDREN` return option for LIST-EXTENDED,
/// while RFC 3348 defines the `\\HasChildren` and `\\HasNoChildren`
/// attributes it surfaces.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_return_children_distinguishes_parent_and_leaf() {
    let conn = connect_dovecot().await;

    let parent = unique_name("test-list-children-parent");
    let child = format!("{parent}/child");
    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();

    let listing = conn
        .list_extended("", &[&parent, &child], &[], &["CHILDREN"], TIMEOUT)
        .await
        .unwrap();

    let parent_entry = listing
        .iter()
        .find(|info| info.name.as_str() == parent)
        .unwrap_or_else(|| panic!("expected parent mailbox in LIST results: {listing:?}"));
    assert!(
        parent_entry
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::HasChildren)),
        "parent mailbox must report \\HasChildren: {:?}",
        parent_entry.attributes
    );

    let child_entry = listing
        .iter()
        .find(|info| info.name.as_str() == child)
        .unwrap_or_else(|| panic!("expected child mailbox in LIST results: {listing:?}"));
    assert!(
        child_entry
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::HasNoChildren)),
        "leaf mailbox must report \\HasNoChildren: {:?}",
        child_entry.attributes
    );

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

/// `uid_fetch_streaming` for a nonexistent UID must emit no FETCH responses
/// and close the receiver cleanly once the command completes.
///
/// RFC 3501 Section 6.4.8 applies UID to FETCH while preserving FETCH's
/// semantics from RFC 3501 Section 6.4.5: only matching messages produce
/// untagged FETCH data, and a missing UID is not an error.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_streaming_nonexistent_uid_returns_no_items() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-uid-fetch-stream-missing");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let existing_uid = conn
        .uid_fetch(&SequenceSet::new("*").unwrap(), &[FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap()
        .pop()
        .and_then(|fetch| fetch.uid)
        .expect("expected UID for appended message");
    let missing_uid = existing_uid + 1000;

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    conn.uid_fetch_streaming(
        &SequenceSet::new(missing_uid.to_string()).unwrap(),
        &[FetchAttr::Uid],
        tx,
        TIMEOUT,
    )
    .await
    .unwrap();

    let mut responses = Vec::new();
    while let Some(result) = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .expect("stream receiver must close promptly after UID FETCH completes")
    {
        responses.push(result.unwrap());
    }

    assert!(
        responses.is_empty(),
        "nonexistent UID must not stream any FETCH responses"
    );

    let buffered = conn
        .uid_fetch(
            &SequenceSet::new(missing_uid.to_string()).unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        buffered.is_empty(),
        "regular UID FETCH must also return no matches for a nonexistent UID"
    );

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

/// `uid_fetch_streaming` against an empty mailbox must emit no FETCH responses
/// and close the receiver once the command completes.
///
/// RFC 3501 Section 6.4.8: nonexistent unique identifiers are ignored without
/// error, so `UID FETCH` over an empty mailbox must stream no items.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_streaming_empty_mailbox_returns_no_items() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-uid-fetch-stream-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    conn.uid_fetch_streaming(
        &SequenceSet::new("999999").unwrap(),
        &[FetchAttr::Uid],
        tx,
        TIMEOUT,
    )
    .await
    .unwrap();

    let mut responses = Vec::new();
    while let Some(result) = tokio::time::timeout(TIMEOUT, rx.recv())
        .await
        .expect("stream receiver must close promptly after UID FETCH completes")
    {
        responses.push(result.unwrap());
    }

    assert!(
        responses.is_empty(),
        "empty mailbox must not stream UID FETCH responses, got: {responses:?}"
    );

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

/// `uid_fetch_streaming` must ignore missing UIDs in a mixed request and
/// still stream the matching messages.
///
/// RFC 3501 Section 6.4.8: nonexistent unique identifiers are ignored
/// without error, so `UID FETCH` over a mixed existing/missing UID set must
/// yield only the existing messages.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_streaming_ignores_missing_uids_in_mixed_set() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-uid-fetch-stream-mixed");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let existing_uid = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .pop()
        .and_then(|fetch| fetch.uid)
        .expect("expected UID for appended message");
    let missing_uid = existing_uid + 1000;

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    conn.uid_fetch_streaming(
        &SequenceSet::new(format!("{existing_uid},{missing_uid}")).unwrap(),
        &[FetchAttr::Uid, FetchAttr::Flags],
        tx,
        TIMEOUT,
    )
    .await
    .unwrap();

    let mut responses = Vec::new();
    while let Some(result) = tokio::time::timeout(TIMEOUT, rx.recv())
        .await
        .expect("stream receiver must close promptly after UID FETCH completes")
    {
        responses.push(result.unwrap());
    }

    assert_eq!(
        responses.len(),
        1,
        "UID FETCH must ignore the missing UID and return only the matching message: {responses:?}"
    );
    assert_eq!(
        responses[0].uid,
        Some(existing_uid),
        "the streamed response must correspond to the existing UID"
    );
    assert!(
        responses[0].flags.is_some(),
        "the streamed response must include FLAGS when requested: {:?}",
        responses[0].flags
    );

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

// =========================================================================
// uid_fetch_changed_since — CONDSTORE (RFC 7162 Section 3.1.4)
// =========================================================================

/// `uid_fetch_changed_since` must return only messages whose mod-sequence
/// is greater than the given value.
///
/// RFC 7162 Section 3.1.4: FETCH with CHANGEDSINCE modifier filters
/// responses to those modified since the specified mod-sequence.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_changed_since() {
    use daaki_imap::types::{Capability, SelectOptions};

    let conn = connect_dovecot().await;

    // Check CONDSTORE support.
    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Condstore)) {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

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

    // SELECT with CONDSTORE to enable mod-sequence tracking.
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

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

    // Re-select with CONDSTORE to get HIGHESTMODSEQ.
    let selected = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();
    let highestmodseq = selected.highest_mod_seq.unwrap_or(0);

    // Fetch with the current HIGHESTMODSEQ — should return nothing since
    // nothing has changed since then.
    let seq = SequenceSet::new("1:*").unwrap();
    let unchanged = conn
        .uid_fetch_changed_since(
            &seq,
            &[FetchAttr::Uid, FetchAttr::Flags],
            highestmodseq,
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        unchanged.is_empty(),
        "fetch_changed_since with current HIGHESTMODSEQ must return empty; got {} items",
        unchanged.len()
    );

    // Modify a flag to bump the modseq.
    let uid_seq = SequenceSet::new("1").unwrap();
    conn.uid_store(
        &uid_seq,
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Now fetch_changed_since with the OLD modseq — should return the
    // modified message.
    let changed = conn
        .uid_fetch_changed_since(
            &seq,
            &[FetchAttr::Uid, FetchAttr::Flags],
            highestmodseq,
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        !changed.is_empty(),
        "fetch_changed_since with stale modseq must return modified messages"
    );

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

/// `uid_fetch_changed_since` must filter a mixed mailbox down to only the
/// messages modified after the baseline mod-sequence.
///
/// RFC 7162 Section 3.1.4: `CHANGEDSINCE` returns only messages whose
/// mod-sequence is greater than the supplied value.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_changed_since_filters_only_modified_messages() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Condstore))
    {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = format!("test-uid-changed-since-filter-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    for i in 1..=2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: UID CHANGEDSINCE filter {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <uid-changed-since-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             UID CHANGEDSINCE filter body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let baseline = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap()
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

    let all_uids = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(
        all_uids.len(),
        2,
        "expected exactly two messages in the test mailbox"
    );

    let changed_uid = all_uids[1];
    conn.uid_store(
        &SequenceSet::new(changed_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let changed = conn
        .uid_fetch_changed_since(
            &SequenceSet::new(format!("{},{}", all_uids[0], all_uids[1])).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::ModSeq],
            baseline,
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        changed.len(),
        1,
        "UID FETCH CHANGEDSINCE must return only the modified message, got: {changed:?}"
    );
    assert_eq!(changed[0].uid, Some(changed_uid));
    assert!(
        changed[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Flagged)),
        "changed message must include the updated \\Flagged flag: {:?}",
        changed[0].flags
    );
    assert!(
        changed[0].mod_seq.is_some_and(|mod_seq| mod_seq > baseline),
        "changed message must report a mod-sequence greater than the baseline: {:?}",
        changed[0].mod_seq
    );

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

/// `uid_fetch_changed_since` must ignore changed messages outside the
/// requested UID set.
///
/// RFC 7162 Section 3.1.4: the `CHANGEDSINCE` modifier narrows the requested
/// `UID FETCH`; changed messages outside that UID set must not be returned.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_changed_since_limits_results_to_requested_uid_set() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Condstore))
    {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-changed-since-subset");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: UID CHANGEDSINCE subset {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <uid-changed-since-subset-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             UID CHANGEDSINCE subset body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let baseline = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap()
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

    let mut uids = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    uids.sort_unstable();
    assert_eq!(uids.len(), 3, "expected three test messages");

    conn.uid_store(
        &SequenceSet::new(uids[0].to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.uid_store(
        &SequenceSet::new(uids[2].to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Seen],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let requested = SequenceSet::new(format!("{},{}", uids[0], uids[1])).unwrap();
    let changed = conn
        .uid_fetch_changed_since(
            &requested,
            &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::ModSeq],
            baseline,
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        changed.len(),
        1,
        "UID FETCH CHANGEDSINCE must return only changes inside the requested UID set: {changed:?}"
    );
    assert_eq!(changed[0].uid, Some(uids[0]));
    assert!(
        changed[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Flagged)),
        "returned UID must include the requested flag update: {:?}",
        changed[0].flags
    );
    assert!(
        changed[0].mod_seq.is_some_and(|mod_seq| mod_seq > baseline),
        "returned UID must include a MODSEQ greater than the baseline: {:?}",
        changed[0].mod_seq
    );

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

/// `uid_fetch_changed_since` must return no results when only out-of-set
/// messages changed after the baseline mod-sequence.
///
/// RFC 7162 Section 3.1.4 scopes `CHANGEDSINCE` to the requested `UID FETCH`
/// set, so unrelated changed UIDs must not leak into the result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_changed_since_returns_empty_when_only_out_of_set_messages_changed() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Condstore))
    {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-changed-since-out-of-set");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: UID CHANGEDSINCE out-of-set {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <uid-changed-since-out-of-set-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             UID CHANGEDSINCE out-of-set body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let baseline = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap()
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

    let mut uids = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    uids.sort_unstable();
    assert_eq!(uids.len(), 3, "expected three test messages");

    conn.uid_store(
        &SequenceSet::new(uids[2].to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let changed = conn
        .uid_fetch_changed_since(
            &SequenceSet::new(format!("{},{}", uids[0], uids[1])).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            baseline,
            TIMEOUT,
        )
        .await
        .unwrap();

    assert!(
        changed.is_empty(),
        "UID FETCH CHANGEDSINCE must not return out-of-set changes: {changed:?}"
    );

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

/// `fetch_changed_since` must filter a mixed mailbox down to only the
/// sequence-numbered messages modified after the baseline mod-sequence.
///
/// RFC 7162 Section 3.1.4: `CHANGEDSINCE` narrows `FETCH` results to the
/// messages whose mod-sequence is greater than the supplied value.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_changed_since_filters_only_modified_messages() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Condstore))
    {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = format!("test-seq-changed-since-filter-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    for i in 1..=2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Sequence CHANGEDSINCE filter {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <seq-changed-since-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Sequence CHANGEDSINCE filter body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let baseline = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap()
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

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

    let changed = conn
        .fetch_changed_since(
            &SequenceSet::new("1:2").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::ModSeq],
            baseline,
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        changed.len(),
        1,
        "FETCH CHANGEDSINCE must return only the modified sequence number, got: {changed:?}"
    );
    assert_eq!(changed[0].seq, 2);
    assert!(
        changed[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Flagged)),
        "changed sequence must include the updated \\Flagged flag: {:?}",
        changed[0].flags
    );
    assert!(
        changed[0].mod_seq.is_some_and(|mod_seq| mod_seq > baseline),
        "changed sequence must report a mod-sequence greater than the baseline: {:?}",
        changed[0].mod_seq
    );

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

// =========================================================================
// create_special_use (RFC 6154 Section 3)
// =========================================================================

/// CREATE with special-use attributes assigns the given role to the
/// mailbox.
///
/// RFC 6154 Section 3: "Clients MUST NOT use the USE parameter unless
/// the server advertises the CREATE-SPECIAL-USE capability."
/// If the server lacks this capability, the test is skipped.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_special_use_mailbox() {
    use daaki_imap::types::{Capability, MailboxAttribute};

    let conn = connect_dovecot().await;

    // Check CREATE-SPECIAL-USE support.
    let caps = conn.capabilities();
    let has_create_special_use = caps
        .iter()
        .any(|c| matches!(c, Capability::CreateSpecialUse));
    if !has_create_special_use {
        eprintln!("skipping: Dovecot does not advertise CREATE-SPECIAL-USE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = format!("test-special-use-{}", std::process::id());
    conn.create_special_use(&mbox, &[MailboxAttribute::Drafts], TIMEOUT)
        .await
        .unwrap();

    // Verify the mailbox exists and has the special-use attribute via LIST.
    let listing = conn.list("", &mbox, TIMEOUT).await.unwrap();
    assert!(
        !listing.is_empty(),
        "newly created special-use mailbox must appear in LIST"
    );

    // Check the special-use attribute is present.
    let found = listing.iter().any(|m| {
        m.attributes
            .iter()
            .any(|a| matches!(a, MailboxAttribute::Drafts))
    });
    assert!(
        found,
        "created mailbox must have \\Drafts attribute; listing: {listing:?}"
    );

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

/// `create_special_use_with_mailbox_id` must apply the requested special-use
/// role and return a non-empty MAILBOXID when OBJECTID is advertised.
///
/// RFC 6154 Section 3: CREATE USE assigns a special-use role to the mailbox.
/// RFC 8474 Section 4.1: servers advertising OBJECTID must include MAILBOXID
/// on successful CREATE responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_create_special_use_with_mailbox_id() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    let has_create_special_use = caps
        .iter()
        .any(|c| matches!(c, Capability::CreateSpecialUse));
    if !has_create_special_use {
        eprintln!("skipping: Dovecot does not advertise CREATE-SPECIAL-USE");
        conn.logout().await.unwrap();
        return;
    }
    let has_objectid = caps.iter().any(|c| matches!(c, Capability::ObjectId));

    let mbox = format!("test-special-use-mailboxid-{}", std::process::id());
    let mailbox_id = conn
        .create_special_use_with_mailbox_id(&mbox, &[MailboxAttribute::Sent], TIMEOUT)
        .await
        .unwrap();

    if has_objectid {
        let id = mailbox_id.expect("OBJECTID server must return MAILBOXID on CREATE");
        assert!(!id.is_empty(), "MAILBOXID must not be empty");
    } else {
        assert!(
            mailbox_id.is_none(),
            "without OBJECTID, MAILBOXID should be absent"
        );
    }

    let listing = conn.list("", &mbox, TIMEOUT).await.unwrap();
    assert_eq!(listing.len(), 1, "created mailbox must appear in LIST");
    assert!(
        listing[0]
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::Sent)),
        "created mailbox must advertise the requested \\Sent role: {:?}",
        listing[0].attributes
    );

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

/// `LIST RETURN (SPECIAL-USE)` must surface the special-use attribute for
/// a mailbox created with `CREATE USE`.
///
/// RFC 5258 Section 3 defines LIST-EXTENDED return options, and RFC 6154
/// Section 2 adds the `SPECIAL-USE` return option for exposing mailbox roles.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_return_special_use_surfaces_role() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps
        .iter()
        .any(|c| matches!(c, Capability::CreateSpecialUse))
        || !caps.iter().any(|c| matches!(c, Capability::ListExtended))
        || !caps.iter().any(|c| matches!(c, Capability::SpecialUse))
    {
        eprintln!(
            "skipping: Dovecot must advertise CREATE-SPECIAL-USE, LIST-EXTENDED, and SPECIAL-USE"
        );
        conn.logout().await.unwrap();
        return;
    }

    let mbox = format!("test-list-return-special-use-{}", std::process::id());
    conn.create_special_use(&mbox, &[MailboxAttribute::Archive], TIMEOUT)
        .await
        .unwrap();

    let listing = conn
        .list_extended("", &[&mbox], &[], &["SPECIAL-USE"], TIMEOUT)
        .await
        .unwrap();

    assert_eq!(
        listing.len(),
        1,
        "LIST-EXTENDED should return the requested mailbox"
    );
    assert_eq!(listing[0].name.as_str(), mbox);
    assert!(
        listing[0]
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::Archive)),
        "LIST RETURN (SPECIAL-USE) must surface the created \\Archive role: {:?}",
        listing[0].attributes
    );

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

/// `LIST (SPECIAL-USE)` must filter out ordinary mailboxes and return only
/// mailboxes carrying special-use attributes.
///
/// RFC 6154 Section 2: the `SPECIAL-USE` selection option limits LIST
/// results to mailboxes with special-use roles.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_special_use_selection_filters_non_special_mailboxes() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps
        .iter()
        .any(|c| matches!(c, Capability::CreateSpecialUse))
        || !caps.iter().any(|c| matches!(c, Capability::ListExtended))
        || !caps.iter().any(|c| matches!(c, Capability::SpecialUse))
    {
        eprintln!(
            "skipping: Dovecot must advertise CREATE-SPECIAL-USE, LIST-EXTENDED, and SPECIAL-USE"
        );
        conn.logout().await.unwrap();
        return;
    }

    let prefix = format!("test-list-select-special-use-{}", std::process::id());
    let special_mailbox = format!("{prefix}-archive");
    let regular_mailbox = format!("{prefix}-plain");
    conn.create_special_use(&special_mailbox, &[MailboxAttribute::Archive], TIMEOUT)
        .await
        .unwrap();
    conn.create(&regular_mailbox, TIMEOUT).await.unwrap();

    let listing = conn
        .list_extended("", &[&format!("{prefix}*")], &["SPECIAL-USE"], &[], TIMEOUT)
        .await
        .unwrap();

    assert!(
        listing
            .iter()
            .any(|info| info.name.as_str() == special_mailbox),
        "LIST (SPECIAL-USE) must include the special-use mailbox: {listing:?}"
    );
    assert!(
        listing
            .iter()
            .all(|info| info.name.as_str() != regular_mailbox),
        "LIST (SPECIAL-USE) must exclude ordinary mailboxes: {listing:?}"
    );

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

// =========================================================================
// drain_events after APPEND (RFC 3501 Section 7.3.1)
// =========================================================================

/// After an APPEND, `drain_events()` may contain an EXISTS update
/// from the server.
///
/// RFC 3501 Section 7.3.1: "the server sends an EXISTS response when
/// the size of the mailbox changes". This tests that async events
/// are properly surfaced via `drain_events()`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_after_operations() {
    let conn = connect_dovecot().await;

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

    // Append a message — this may generate EXISTS/RECENT events.
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // Flush pending unsolicited responses through the driver by issuing
    // a NOOP round-trip. This is deterministic — unlike a sleep, it
    // guarantees the driver has read everything the server sent before
    // the tagged NOOP OK.
    let (noop_result,) = conn.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

    // drain_events should return without blocking and surface the
    // EXISTS notification the server sent after the APPEND.
    let events = conn.drain_events().await;
    assert!(
        !events.is_empty(),
        "drain_events after APPEND + NOOP flush must surface at least one event (e.g. EXISTS)"
    );

    // Connection must still be usable.
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

// =========================================================================
// Pipeline-only commands (RFC 3501 §5.5, RFC 2342, RFC 2971)
// =========================================================================

/// Pipeline NOOP in authenticated state.
///
/// NOOP (RFC 3501 Section 6.1.2 / RFC 9051 Section 6.1.2) must succeed in
/// any state. It is only available through the pipeline API.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_noop() {
    let conn = connect_dovecot().await;

    let (noop_result,) = conn.pipeline().noop().execute().await.unwrap();

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

/// Pipeline NOOP in selected state.
///
/// RFC 3501 Section 6.1.2: NOOP is valid in any state. Verify it works
/// after SELECT as well, which may trigger untagged responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_noop_selected() {
    let conn = connect_dovecot().await;

    conn.select("INBOX", TIMEOUT).await.unwrap();
    let (noop_result,) = conn.pipeline().noop().execute().await.unwrap();

    noop_result.unwrap();
    conn.close(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// Pipeline CAPABILITY returns IMAP4rev1.
///
/// RFC 3501 Section 6.1.1: CAPABILITY response MUST include IMAP4rev1.
/// After authentication, the server may advertise additional capabilities.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_capability() {
    let conn = connect_dovecot().await;

    let (caps_result,) = conn.pipeline().capability().execute().await.unwrap();

    let caps = caps_result.unwrap();
    assert!(
        caps.contains(&Capability::Imap4Rev1),
        "server MUST advertise IMAP4rev1 per RFC 3501 Section 6.1.1, got: {caps:?}"
    );
    conn.logout().await.unwrap();
}

/// Pipeline NAMESPACE returns a personal namespace.
///
/// RFC 2342 Section 5: a server that supports NAMESPACE MUST include at
/// least a personal namespace. Dovecot advertises NAMESPACE in its
/// capability list.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_namespace() {
    let conn = connect_dovecot().await;

    // Verify NAMESPACE is advertised before attempting the command.
    let caps = conn.capabilities();
    if !caps.contains(&Capability::Namespace) {
        eprintln!("skipping: Dovecot does not advertise NAMESPACE");
        conn.logout().await.unwrap();
        return;
    }

    let (ns_result,) = conn.pipeline().namespace().execute().await.unwrap();

    let ns = ns_result.unwrap();
    assert!(
        !ns.personal.is_empty(),
        "RFC 2342 Section 5: server MUST return at least one personal namespace"
    );
    conn.logout().await.unwrap();
}

/// Pipeline ID exchanges client/server identification.
///
/// RFC 2971 Section 3.1: the server responds with its own ID fields
/// (name, version, etc.) or NIL.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_id() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.contains(&Capability::Id) {
        eprintln!("skipping: Dovecot does not advertise ID");
        conn.logout().await.unwrap();
        return;
    }

    let client_id = vec![
        ("name".to_string(), Some("daaki-test".to_string())),
        ("version".to_string(), Some("0.1.0".to_string())),
    ];
    let (id_result,) = conn.pipeline().id(client_id).execute().await.unwrap();

    let server_id = id_result.unwrap();
    // RFC 2971 Section 3.1: the response is a list of field/value pairs
    // or NIL (which the library represents as an empty vec).
    // Dovecot typically responds with "name" "Dovecot".
    if !server_id.is_empty() {
        let has_name = server_id
            .iter()
            .any(|(k, _)| k.eq_ignore_ascii_case("name"));
        assert!(
            has_name,
            "RFC 2971: server ID response should include a 'name' field, got: {server_id:?}"
        );
    }
    conn.logout().await.unwrap();
}

// =========================================================================
// Pipeline NOTIFY (RFC 5465)
// =========================================================================

/// Pipeline `NOTIFY SET` and `NOTIFY NONE` must both succeed for
/// `selected` message notifications.
///
/// RFC 5465 Section 3 defines NOTIFY as a `command-auth` extension and says
/// `NOTIFY NONE` replaces the prior registration with baseline IMAP behavior.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_notify_set_selected_then_none() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("pipe-notify-selected-user");
    let conn = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&conn).await {
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-pipe-notify-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::Selected,
            vec![
                NotifyEvent::MessageNew {
                    fetch_attrs: vec![],
                },
                NotifyEvent::MessageExpunge,
            ],
        )],
        false,
    );
    let (notify_result,) = conn.pipeline().notify_set(params).execute().await.unwrap();
    let overflow = notify_result
        .unwrap()
        .expect("pipeline NOTIFY SET should succeed");
    assert!(
        !overflow,
        "Dovecot should not report NOTIFICATIONOVERFLOW for a fresh registration"
    );

    let (notify_none_result,) = conn.pipeline().notify_none().execute().await.unwrap();
    notify_none_result.unwrap();

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

/// Pipeline `NOTIFY SET STATUS` must deliver an initial STATUS snapshot for
/// matched personal mailboxes before the tagged OK.
///
/// RFC 5465 Section 4 requires the server to send STATUS responses for each
/// matching non-selected mailbox when the `STATUS` indicator is present.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_notify_status_indicator_delivers_initial_snapshot() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("pipe-notify-status-user");
    let conn = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&conn).await {
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-pipe-notify-status");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::Personal,
            vec![
                NotifyEvent::MessageNew {
                    fetch_attrs: vec![],
                },
                NotifyEvent::MessageExpunge,
            ],
        )],
        true,
    );
    let (notify_result,) = conn.pipeline().notify_set(params).execute().await.unwrap();
    let overflow = notify_result
        .unwrap()
        .expect("pipeline NOTIFY SET STATUS should succeed");
    assert!(
        !overflow,
        "initial STATUS snapshot should not clear the registration with NOTIFICATIONOVERFLOW"
    );

    let events = conn.drain_events().await;
    assert!(
        events
            .iter()
            .any(|event| is_mailbox_status_for(event, &mailbox)),
        "NOTIFY SET STATUS must deliver a STATUS snapshot for {mailbox:?}; got {events:?}"
    );

    let (notify_none_result,) = conn.pipeline().notify_none().execute().await.unwrap();
    notify_none_result.unwrap();

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

/// Pipeline `NOTIFY SET` with the `mailboxes` filter must deliver
/// `MailboxName` events only for the explicitly named mailbox.
///
/// RFC 5465 Section 6.7 defines `mailboxes` as an exact-name filter with no
/// wildcard expansion.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_notify_mailboxes_filter_delivers_exact_mailbox_create_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("pipe-notify-mailboxes-user");
    let watcher = connect_dovecot_as(&user).await;
    let actor = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-pipe-notify-mailboxes");
    let other_mailbox = unique_name("test-pipe-notify-mailboxes-other");
    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::Mailboxes(vec![mailbox.clone()]),
            vec![NotifyEvent::MailboxName],
        )],
        false,
    );
    let (notify_result,) = watcher
        .pipeline()
        .notify_set(params)
        .execute()
        .await
        .unwrap();
    let overflow = notify_result
        .unwrap()
        .expect("pipeline NOTIFY SET should succeed");
    assert!(!overflow, "exact mailbox registration should not overflow");
    let _ = watcher.drain_events().await;

    actor.create(&other_mailbox, TIMEOUT).await.unwrap();
    let unrelated_events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(2),
        |event| is_mailbox_event_for(event, &other_mailbox),
    )
    .await;

    actor.create(&mailbox, TIMEOUT).await.unwrap();
    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(3),
        |event| is_mailbox_event_for(event, &mailbox),
    )
    .await;

    let (notify_none_result,) = watcher.pipeline().notify_none().execute().await.unwrap();
    notify_none_result.unwrap();

    actor.delete(&other_mailbox, TIMEOUT).await.unwrap();
    actor.delete(&mailbox, TIMEOUT).await.unwrap();
    actor.logout().await.unwrap();
    watcher.logout().await.unwrap();

    assert!(
        !unrelated_events
            .iter()
            .any(|event| is_mailbox_event_for(event, &other_mailbox)),
        "mailboxes filter must not deliver MailboxName for unlisted mailbox {other_mailbox:?}; got {unrelated_events:?}"
    );
    assert!(
        events
            .iter()
            .any(|event| is_mailbox_event_for(event, &mailbox)),
        "mailboxes filter must deliver a MailboxName event for {mailbox:?}; got {events:?}"
    );
    assert!(
        !events
            .iter()
            .any(|event| is_mailbox_event_for(event, &other_mailbox)),
        "mailboxes filter must not leak unlisted mailbox {other_mailbox:?} alongside the matching event; got {events:?}"
    );
}

/// Pipeline `NOTIFY SET` with the `subtree` filter must deliver a
/// `MailboxName` event for a newly created child mailbox.
///
/// RFC 5465 Section 6.6 defines `subtree` as the named mailbox plus all
/// subordinate mailboxes.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_notify_subtree_filter_delivers_child_mailbox_create_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("pipe-notify-subtree-user");
    let watcher = connect_dovecot_as(&user).await;
    let actor = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }

    let parent = unique_name("test-pipe-notify-subtree");
    watcher.create(&parent, TIMEOUT).await.unwrap();
    let delimiter = hierarchy_delimiter(&watcher).await;
    let child = format!("{parent}{delimiter}{}", unique_name("child"));

    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::Subtree(vec![parent.clone()]),
            vec![NotifyEvent::MailboxName],
        )],
        false,
    );
    let (notify_result,) = watcher
        .pipeline()
        .notify_set(params)
        .execute()
        .await
        .unwrap();
    let overflow = notify_result
        .unwrap()
        .expect("pipeline NOTIFY SET should succeed");
    assert!(!overflow, "subtree registration should not overflow");
    let _ = watcher.drain_events().await;

    actor.create(&child, TIMEOUT).await.unwrap();
    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(3),
        |event| is_mailbox_event_for(event, &child),
    )
    .await;
    assert!(
        events.iter().any(|event| is_mailbox_event_for(event, &child)),
        "subtree filter must deliver a MailboxName event for child mailbox {child:?}; got {events:?}"
    );

    let (notify_none_result,) = watcher.pipeline().notify_none().execute().await.unwrap();
    notify_none_result.unwrap();

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

/// After pipeline `NOTIFY NONE`, later matching mailbox creations must no
/// longer surface `MailboxName` events.
///
/// RFC 5465 Section 3 says `NOTIFY NONE` cancels the active registration and
/// restores baseline IMAP behavior.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_notify_none_suppresses_followup_mailbox_create_event() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("pipe-notify-none-user");
    let watcher = connect_dovecot_as(&user).await;
    let actor = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-pipe-notify-none");
    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::Mailboxes(vec![mailbox.clone()]),
            vec![NotifyEvent::MailboxName],
        )],
        false,
    );
    let (notify_result,) = watcher
        .pipeline()
        .notify_set(params)
        .execute()
        .await
        .unwrap();
    let overflow = notify_result
        .unwrap()
        .expect("pipeline NOTIFY SET should succeed");
    assert!(!overflow, "exact mailbox registration should not overflow");

    let (notify_none_result,) = watcher.pipeline().notify_none().execute().await.unwrap();
    notify_none_result.unwrap();
    let _ = watcher.drain_events().await;

    actor.create(&mailbox, TIMEOUT).await.unwrap();
    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(2),
        |event| is_mailbox_event_for(event, &mailbox),
    )
    .await;
    assert!(
        !events
            .iter()
            .any(|event| is_mailbox_event_for(event, &mailbox)),
        "NOTIFY NONE must suppress later MailboxName events for {mailbox:?}; got {events:?}"
    );

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

/// Pipeline `NOTIFY SET` with `selected-delayed` must defer EXPUNGE delivery
/// until a command such as `NOOP` permits it.
///
/// RFC 5465 Section 6.2 says `selected-delayed` delays `MessageExpunge`
/// notifications until the client issues a command that can return expunges.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_notify_selected_delayed_expunge_arrives_on_noop() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("pipe-notify-selected-delayed-user");
    let watcher = connect_dovecot_as(&user).await;
    let expunger = connect_dovecot_as(&user).await;
    if !require_dovecot_notify(&watcher).await {
        expunger.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-pipe-notify-selected-delayed");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    watcher.select(&mailbox, TIMEOUT).await.unwrap();
    watcher
        .append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let _ = watcher.drain_events().await;

    let params = NotifySetParams::new(
        vec![NotifyEventGroup::new(
            MailboxFilter::SelectedDelayed,
            vec![
                NotifyEvent::MessageNew {
                    fetch_attrs: vec![],
                },
                NotifyEvent::MessageExpunge,
            ],
        )],
        false,
    );
    let (notify_result,) = watcher
        .pipeline()
        .notify_set(params)
        .execute()
        .await
        .unwrap();
    let overflow = notify_result
        .unwrap()
        .expect("pipeline NOTIFY SET should succeed");
    assert!(
        !overflow,
        "selected-delayed registration should not overflow"
    );
    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();

    tokio::time::sleep(Duration::from_millis(250)).await;
    let before_noop = watcher.drain_events().await;
    assert!(
        !before_noop
            .iter()
            .any(|event| matches!(event, TypedEvent::Expunge(1))),
        "selected-delayed must not emit EXPUNGE before a synchronization command; got {before_noop:?}"
    );

    let (noop_result,) = watcher.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();
    let after_noop = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(2),
        |event| matches!(event, TypedEvent::Expunge(1)),
    )
    .await;
    assert!(
        after_noop
            .iter()
            .any(|event| matches!(event, TypedEvent::Expunge(1))),
        "selected-delayed must emit EXPUNGE after NOOP; got {after_noop:?}"
    );

    let (notify_none_result,) = watcher.pipeline().notify_none().execute().await.unwrap();
    notify_none_result.unwrap();

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

/// Pipeline CHECK in selected state.
///
/// RFC 3501 Section 6.4.1 / RFC 9051 Section 6.4.1: CHECK requests a
/// checkpoint of the currently selected mailbox. It is a no-op on most
/// servers but must succeed.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_check_in_selected() {
    let conn = connect_dovecot().await;

    conn.select("INBOX", TIMEOUT).await.unwrap();
    let (check_result,) = conn.pipeline().check().execute().await.unwrap();

    check_result.unwrap();
    conn.close(TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// Pipeline with three heterogeneous commands: NOOP + CAPABILITY + STATUS.
///
/// Verifies the pipeline correctly demultiplexes responses to distinct
/// command types. RFC 3501 Section 5.5 permits clients to send multiple
/// commands without waiting for individual responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_mixed_noop_capability_status() {
    let conn = connect_dovecot().await;

    let inbox = MailboxName::new("INBOX").unwrap();

    let (noop_result, caps_result, status_result) = conn
        .pipeline()
        .noop()
        .capability()
        .status(inbox, "(MESSAGES UIDNEXT)".to_string())
        .execute()
        .await
        .unwrap();

    noop_result.unwrap();

    let caps = caps_result.unwrap();
    assert!(
        caps.contains(&Capability::Imap4Rev1),
        "CAPABILITY in mixed pipeline must still return IMAP4rev1"
    );

    let status = status_result.unwrap();
    assert!(
        !status.items.is_empty(),
        "STATUS in mixed pipeline must return at least one item"
    );
    conn.logout().await.unwrap();
}

/// Pipeline LIST-STATUS for an exact mailbox 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_pipeline_list_status_exact_mailbox_reports_message_count() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-pipe-list-status-exact");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (list_status_result,) = conn
        .pipeline()
        .list_status(
            String::new(),
            mailbox.clone(),
            "MESSAGES UIDNEXT".to_owned(),
        )
        .execute()
        .await
        .unwrap();

    let results = list_status_result.unwrap().unwrap();
    assert_eq!(
        results.len(),
        1,
        "pipeline LIST-STATUS should return exactly one mailbox: {results:?}"
    );
    assert_eq!(results[0].0.name.as_str(), mailbox.as_str());
    assert_eq!(
        status_messages(&results[0].1),
        Some(2),
        "pipeline LIST-STATUS must report the appended message count: {:?}",
        results[0].1
    );
    assert!(
        status_uidnext(&results[0].1).is_some_and(|uidnext| uidnext >= 3),
        "pipeline LIST-STATUS must report UIDNEXT after the appended messages: {:?}",
        results[0].1
    );

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

/// Pipeline 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_pipeline_list_status_wildcard_correlates_each_mailbox() {
    let conn = connect_dovecot().await;

    let prefix = unique_name("test-pipe-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_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_two, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_two, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (list_status_result,) = conn
        .pipeline()
        .list_status(String::new(), format!("{prefix}*"), "MESSAGES".to_owned())
        .execute()
        .await
        .unwrap();

    let results = list_status_result.unwrap().unwrap();
    assert_eq!(
        results.len(),
        2,
        "wildcard pipeline LIST-STATUS should return exactly the two matching mailboxes: {results:?}"
    );
    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!(status_messages(&first.1), Some(1));
    assert_eq!(status_messages(&second.1), Some(2));

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

/// Pipeline LIST-STATUS must resolve a relative mailbox name against the
/// supplied reference name (RFC 5819 Section 2, RFC 3501 Section 6.3.8).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_status_reference_resolves_relative_mailbox_name() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-pipe-list-status-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.append(&child, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&child, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (list_status_result,) = conn
        .pipeline()
        .list_status(reference, "child".to_owned(), "MESSAGES UIDNEXT".to_owned())
        .execute()
        .await
        .unwrap();

    let results = list_status_result.unwrap().unwrap();
    assert_eq!(
        results.len(),
        1,
        "relative pipeline LIST-STATUS should resolve to one canonical mailbox: {results:?}"
    );
    assert_eq!(results[0].0.name.as_str(), child.as_str());
    assert_eq!(status_messages(&results[0].1), Some(2));
    assert!(
        status_uidnext(&results[0].1).is_some_and(|uidnext| uidnext >= 3),
        "UIDNEXT should advance past the appended messages: {:?}",
        results[0].1
    );

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

/// Pipeline LIST with a relative mailbox name must resolve it against the
/// supplied reference and return the canonical mailbox name
/// (RFC 3501 Section 6.3.8, RFC 3501 Section 5.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_reference_resolves_relative_mailbox_name() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-pipe-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 (list_result,) = conn
        .pipeline()
        .list(reference, "child".to_owned())
        .execute()
        .await
        .unwrap();

    let listing = list_result.unwrap().unwrap();
    assert_eq!(
        listing.len(),
        1,
        "pipeline 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();
}

/// Pipeline LIST with `%` and a non-empty reference must match only the
/// direct child mailboxes below that reference
/// (RFC 3501 Section 6.3.8, RFC 3501 Section 5.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-pipe-list-reference-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 (list_result,) = conn
        .pipeline()
        .list(reference, "%".to_owned())
        .execute()
        .await
        .unwrap();

    let listing = list_result.unwrap().unwrap();
    let mut names: Vec<_> = listing
        .iter()
        .map(|info| info.name.as_str().to_owned())
        .collect();
    names.sort();
    assert_eq!(
        names,
        vec![child.clone()],
        "pipeline 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();
}

/// Pipeline LIST-EXTENDED with `RETURN (CHILDREN)` and `%` under a non-empty
/// reference must match only the direct child mailboxes below that reference
/// (RFC 5258 Section 3, RFC 3501 Section 6.3.8, RFC 3348, RFC 3501 Section 5.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_extended_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-pipe-list-extended-reference-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 (list_result,) = conn
        .pipeline()
        .list_extended(
            vec![],
            reference,
            vec!["%".to_owned()],
            vec!["CHILDREN".to_owned()],
        )
        .execute()
        .await
        .unwrap();

    let listing = list_result.unwrap().unwrap();
    let mut names: Vec<_> = listing
        .iter()
        .map(|info| info.name.as_str().to_owned())
        .collect();
    names.sort();
    assert_eq!(
        names,
        vec![child.clone()],
        "pipeline LIST-EXTENDED % under a reference must return only direct children: {listing:?}"
    );
    assert!(
        listing[0]
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::HasChildren)),
        "pipeline LIST-EXTENDED RETURN (CHILDREN) should still surface child hierarchy metadata: {:?}",
        listing[0].attributes
    );

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

/// Pipeline LIST-STATUS with `%` and a non-empty reference must report only
/// the direct child mailboxes below that reference and correlate STATUS data
/// per mailbox (RFC 5819 Section 2, RFC 3501 Section 6.3.8, RFC 3501 Section 5.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_status_reference_percent_matches_only_direct_children() {
    let conn = connect_dovecot().await;

    let delimiter = hierarchy_delimiter(&conn).await;
    let parent = unique_name("test-pipe-list-status-reference-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.append(&child, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&grandchild, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&grandchild, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (list_status_result,) = conn
        .pipeline()
        .list_status(reference, "%".to_owned(), "MESSAGES UIDNEXT".to_owned())
        .execute()
        .await
        .unwrap();

    let results = list_status_result.unwrap().unwrap();
    assert_eq!(
        results.len(),
        1,
        "pipeline LIST-STATUS % under a reference must return only direct children: {results:?}"
    );
    assert_eq!(results[0].0.name.as_str(), child.as_str());
    assert_eq!(
        status_messages(&results[0].1),
        Some(1),
        "pipeline LIST-STATUS must correlate the direct child mailbox with its own STATUS data: {:?}",
        results[0].1
    );
    assert!(
        status_uidnext(&results[0].1).is_some_and(|uidnext| uidnext >= 2),
        "UIDNEXT should reflect the one appended child message: {:?}",
        results[0].1
    );

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

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

    let selected_mailbox = unique_name("test-pipe-list-status-selected-source");
    let queried_mailbox = unique_name("test-pipe-list-status-selected-target");
    conn.create(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.create(&queried_mailbox, TIMEOUT).await.unwrap();
    conn.append(&selected_mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&selected_mailbox, TIMEOUT).await.unwrap();

    let (list_status_result,) = conn
        .pipeline()
        .list_status(
            String::new(),
            queried_mailbox.clone(),
            "MESSAGES".to_owned(),
        )
        .execute()
        .await
        .unwrap();

    let results = list_status_result.unwrap().unwrap();
    assert_eq!(results.len(), 1, "expected one mailbox result: {results:?}");
    assert_eq!(results[0].0.name.as_str(), queried_mailbox.as_str());
    assert_eq!(status_messages(&results[0].1), Some(0));

    let search = conn.search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        search.ids,
        vec![1],
        "pipeline LIST-STATUS must keep the originally selected mailbox active"
    );
    conn.close(TIMEOUT)
        .await
        .expect("pipeline LIST-STATUS must not clear the selected state");
    conn.delete(&selected_mailbox, TIMEOUT).await.unwrap();
    conn.delete(&queried_mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// Two pipelined LIST-STATUS commands must keep their results separated and
/// correctly correlated to their requested mailbox names (RFC 5819 Section 2,
/// RFC 3501 Section 5.5).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_two_list_status_commands_keep_results_separated() {
    let conn = connect_dovecot().await;

    let mailbox_one = unique_name("test-pipe-list-status-split-one");
    let mailbox_two = unique_name("test-pipe-list-status-split-two");
    conn.create(&mailbox_one, TIMEOUT).await.unwrap();
    conn.create(&mailbox_two, TIMEOUT).await.unwrap();
    conn.append(&mailbox_one, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_two, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox_two, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (first_result, second_result) = conn
        .pipeline()
        .list_status(String::new(), mailbox_one.clone(), "MESSAGES".to_owned())
        .list_status(String::new(), mailbox_two.clone(), "MESSAGES".to_owned())
        .execute()
        .await
        .unwrap();

    let first = first_result.unwrap().unwrap();
    let second = second_result.unwrap().unwrap();

    assert_eq!(first.len(), 1, "first LIST-STATUS should have one result");
    assert_eq!(second.len(), 1, "second LIST-STATUS should have one result");
    assert_eq!(first[0].0.name.as_str(), mailbox_one.as_str());
    assert_eq!(second[0].0.name.as_str(), mailbox_two.as_str());
    assert_eq!(status_messages(&first[0].1), Some(1));
    assert_eq!(status_messages(&second[0].1), Some(2));

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

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

/// Two consecutive IDLE cycles on the same connection.
///
/// RFC 2177 Section 3: the client may issue IDLE multiple times. Verify
/// that cancelling one IDLE and starting another works correctly.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_idle_cancel_then_idle_again() {
    let conn = connect_dovecot().await;

    conn.select("INBOX", TIMEOUT).await.unwrap();

    // First IDLE cycle — cancel immediately.
    let cancel1 = tokio_util::sync::CancellationToken::new();
    let cancel1_clone = cancel1.clone();
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(300)).await;
        cancel1_clone.cancel();
    });
    let event1 = conn.idle(TIMEOUT, cancel1).await.unwrap();
    assert!(
        matches!(event1, daaki_imap::IdleEvent::Cancelled),
        "first IDLE should return Cancelled"
    );

    // Second IDLE cycle — cancel immediately.
    let cancel2 = tokio_util::sync::CancellationToken::new();
    let cancel2_clone = cancel2.clone();
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(300)).await;
        cancel2_clone.cancel();
    });
    let event2 = conn.idle(TIMEOUT, cancel2).await.unwrap();
    assert!(
        matches!(event2, daaki_imap::IdleEvent::Cancelled),
        "second IDLE should return Cancelled"
    );

    // Connection must still be usable after two IDLE cycles.
    let (noop_result,) = conn.pipeline().noop().execute().await.unwrap();
    noop_result.unwrap();

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

// =========================================================================
// APPEND edge cases (RFC 3501 Section 6.3.11)
// =========================================================================

/// APPEND with no flags.
///
/// RFC 3501 Section 6.3.11: when no flag list is specified in APPEND, the
/// server SHOULD set no flags (except possibly \Recent). Verify the
/// appended message does not carry \Seen, \Flagged, etc.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_no_flags_empty_set() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-append-noflags-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let result = conn
        .append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (uid_validity, uid) = result.expect("APPENDUID response expected");
    assert!(uid_validity > 0);

    let set = SequenceSet::new(uid.to_string()).unwrap();
    let fetched = conn
        .uid_fetch(&set, &[FetchAttr::Flags, FetchAttr::Uid], TIMEOUT)
        .await
        .unwrap();

    assert_eq!(fetched.len(), 1, "should fetch exactly 1 message");
    let flags = fetched[0].flags.as_deref().unwrap_or(&[]);
    // \Recent is allowed; system flags like \Seen, \Flagged, \Answered,
    // \Deleted, \Draft should NOT be present.
    for flag in flags {
        match flag {
            Flag::Recent => {} // allowed
            Flag::Seen | Flag::Flagged | Flag::Answered | Flag::Deleted | Flag::Draft => {
                panic!("RFC 3501 Section 6.3.11: APPEND with no flags should not set {flag:?}");
            }
            _ => {} // custom keywords — tolerate per Postel's law
        }
    }

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

// =========================================================================
// ESEARCH return options (RFC 4731)
// =========================================================================

/// ESEARCH with MIN and MAX return options.
///
/// RFC 4731 Section 3.1: MIN returns the lowest matching UID/sequence
/// number, MAX returns the highest. Verify both are present and that
/// MIN <= MAX.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_esearch_min_max() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-esearch-minmax-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Append 3 messages so MIN and MAX differ.
    for i in 0..3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: ESEARCH min-max test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <esearch-minmax-{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",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let esearch = conn
        .uid_search_esearch("ALL", &["MIN", "MAX", "COUNT"], TIMEOUT)
        .await
        .unwrap();

    assert_eq!(
        esearch.count,
        Some(3),
        "COUNT must be 3 for 3 appended messages"
    );
    assert!(esearch.min.is_some(), "MIN must be present");
    assert!(esearch.max.is_some(), "MAX must be present");
    assert!(
        esearch.min.unwrap() <= esearch.max.unwrap(),
        "MIN ({}) must be <= MAX ({})",
        esearch.min.unwrap(),
        esearch.max.unwrap()
    );

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

/// Sequence-number ESEARCH with MIN, MAX, and COUNT.
///
/// RFC 4731 Section 3.1: same as UID variant but using sequence numbers.
/// Verifies the non-UID `search_esearch` path.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_esearch_seq_min_max() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-esearch-seq-minmax-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    for i in 0..3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: ESEARCH seq test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <esearch-seq-{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",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let esearch = conn
        .search_esearch("ALL", &["MIN", "MAX", "COUNT"], TIMEOUT)
        .await
        .unwrap();

    assert_eq!(esearch.count, Some(3));
    assert!(esearch.min.is_some(), "MIN must be present");
    assert!(esearch.max.is_some(), "MAX must be present");
    // Sequence numbers start at 1, so MIN should be 1 and MAX should be 3.
    assert_eq!(esearch.min, Some(1), "MIN sequence number should be 1");
    assert_eq!(esearch.max, Some(3), "MAX sequence number should be 3");

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

// =========================================================================
// SEARCH with date criteria (RFC 3501 Section 6.4.4)
// =========================================================================

/// SEARCH with SINCE and BEFORE date criteria.
///
/// RFC 3501 Section 6.4.4: SINCE matches messages with an internal date
/// on or after the specified date; BEFORE matches messages with an
/// internal date strictly before the specified date.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_date_criteria() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-search-date-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Append a message (internal date will be "now").
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // SINCE a date far in the past should include our message.
    let result_since = conn.uid_search("SINCE 01-Jan-2020", TIMEOUT).await.unwrap();
    assert!(
        !result_since.ids.is_empty(),
        "SINCE 2020 should match a message appended today"
    );

    // BEFORE a date far in the past should NOT include our message.
    let result_before = conn
        .uid_search("BEFORE 01-Jan-2020", TIMEOUT)
        .await
        .unwrap();
    assert!(
        result_before.ids.is_empty(),
        "BEFORE 2020 should not match a message appended today"
    );

    // SINCE a date far in the future should NOT include our message.
    let result_future = conn.uid_search("SINCE 01-Jan-2099", TIMEOUT).await.unwrap();
    assert!(
        result_future.ids.is_empty(),
        "SINCE 2099 should not match a message appended today"
    );

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

// =========================================================================
// fetch_changed_since (sequence variant) — CONDSTORE (RFC 7162 Section 3.1.4)
// =========================================================================

/// `fetch_changed_since` (non-UID) must return only messages whose
/// mod-sequence is strictly greater than the supplied threshold.
///
/// RFC 7162 Section 3.1.4: The CHANGEDSINCE FETCH modifier filters results
/// by mod-sequence value. This exercises the sequence-number variant
/// (`fetch_changed_since`), complementing the UID variant already tested.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_changed_since_by_sequence() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

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

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

    // SELECT with CONDSTORE to get HIGHESTMODSEQ.
    let selected = conn
        .select_with(mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();
    let initial_modseq = selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ should be present with CONDSTORE");
    assert!(selected.exists >= 1, "expected at least 1 message");

    // fetch_changed_since with the current HIGHESTMODSEQ — nothing has
    // changed since the SELECT, so the result must be empty.
    let unchanged = conn
        .fetch_changed_since(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags, FetchAttr::Uid],
            initial_modseq,
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        unchanged.is_empty(),
        "fetch_changed_since at current modseq must return empty; got {} items",
        unchanged.len()
    );

    // Mutate the message to advance the modseq.
    conn.store(
        &SequenceSet::new("1").unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Now fetch_changed_since with the OLD modseq — should return the
    // modified message.
    let changed = conn
        .fetch_changed_since(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags, FetchAttr::Uid],
            initial_modseq,
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        !changed.is_empty(),
        "fetch_changed_since with stale modseq must return modified messages"
    );

    // Every returned message should have MODSEQ > initial_modseq.
    for f in &changed {
        let ms = f.mod_seq.expect("CONDSTORE FETCH should include MODSEQ");
        assert!(
            ms > initial_modseq,
            "returned MODSEQ {ms} should be > threshold {initial_modseq}"
        );
    }

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

/// `fetch_changed_since` must ignore changed messages outside the requested
/// sequence-number set.
///
/// RFC 7162 Section 3.1.4 narrows `FETCH` results to the requested sequence
/// set whose mod-sequence is greater than the supplied baseline.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_changed_since_limits_results_to_requested_sequence_set() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Condstore))
    {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-fetch-changed-since-subset");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: FETCH CHANGEDSINCE subset {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <fetch-changed-since-subset-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             FETCH CHANGEDSINCE subset body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let baseline = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap()
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

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

    let changed = conn
        .fetch_changed_since(
            &SequenceSet::new("2:3").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags, FetchAttr::ModSeq],
            baseline,
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(
        changed.len(),
        1,
        "FETCH CHANGEDSINCE must return only changes inside the requested sequence set: {changed:?}"
    );
    assert_eq!(changed[0].seq, 3);
    assert!(
        changed[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Seen)),
        "returned sequence must include the requested flag update: {:?}",
        changed[0].flags
    );
    assert!(
        changed[0].mod_seq.is_some_and(|mod_seq| mod_seq > baseline),
        "returned sequence must include a MODSEQ greater than the baseline: {:?}",
        changed[0].mod_seq
    );

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

/// `fetch_changed_since` must return no results when only out-of-set
/// sequence numbers changed after the baseline mod-sequence.
///
/// RFC 7162 Section 3.1.4 scopes `CHANGEDSINCE` to the requested `FETCH`
/// sequence set, so unrelated changed sequence numbers must not leak into the
/// result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_changed_since_returns_empty_when_only_out_of_set_messages_changed() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Condstore))
    {
        eprintln!("skipping: Dovecot does not advertise CONDSTORE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-fetch-changed-since-out-of-set");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: FETCH CHANGEDSINCE out-of-set {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <fetch-changed-since-out-of-set-{i}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             FETCH CHANGEDSINCE out-of-set body {i}.\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let baseline = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap()
        .highest_mod_seq
        .expect("CONDSTORE SELECT must return HIGHESTMODSEQ");

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

    let changed = conn
        .fetch_changed_since(
            &SequenceSet::new("1:2").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            baseline,
            TIMEOUT,
        )
        .await
        .unwrap();

    assert!(
        changed.is_empty(),
        "FETCH CHANGEDSINCE must not return out-of-set changes: {changed:?}"
    );

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

// =========================================================================
// EXAMINE with CONDSTORE (RFC 7162 Section 3.1.2)
// =========================================================================

/// EXAMINE with CONDSTORE option should return HIGHESTMODSEQ and open the
/// mailbox in read-only mode.
///
/// RFC 7162 Section 3.1.2.1: A server that supports CONDSTORE MUST return
/// HIGHESTMODSEQ when a client issues SELECT/EXAMINE with CONDSTORE.
/// This test exercises `examine_with(SelectOptions::condstore())`, which
/// doesn't require ENABLE and works with Dovecot's implicit CONDSTORE.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_examine_condstore_highestmodseq() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

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

    // Append a message so the mailbox is non-empty.
    conn.append(mbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // EXAMINE with CONDSTORE.
    let examined = conn
        .examine_with(mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

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

    // Must return HIGHESTMODSEQ.
    assert!(
        examined.highest_mod_seq.is_some(),
        "EXAMINE with CONDSTORE should return HIGHESTMODSEQ"
    );

    // Must report at least one message.
    assert_eq!(examined.exists, 1, "expected 1 message in mailbox");

    // UIDVALIDITY must be present and non-zero.
    let uid_validity = examined
        .uid_validity
        .expect("UIDVALIDITY should be present");
    assert!(uid_validity > 0, "UIDVALIDITY should be > 0");

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

// =========================================================================
// Pipeline execute_dynamic (typed pipeline API)
// =========================================================================

/// `execute_dynamic` returns a Vec of boxed results, one per pipelined
/// command. This exercises the dynamic (type-erased) pipeline path.
///
/// This tests the NOOP, CAPABILITY, and STATUS commands pipelined together
/// via `execute_dynamic` rather than the typed `execute` method.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_execute_dynamic() {
    let conn = connect_dovecot().await;

    let results = conn
        .pipeline()
        .noop()
        .capability()
        .noop()
        .execute_dynamic()
        .await
        .unwrap();

    assert_eq!(
        results.len(),
        3,
        "expected 3 results from 3-command pipeline, got {}",
        results.len()
    );

    // Each result should be Ok — NOOP and CAPABILITY should not fail.
    for (i, r) in results.iter().enumerate() {
        assert!(r.is_ok(), "pipeline command {i} should succeed, got: {r:?}");
    }

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

// =========================================================================
// LIST-EXTENDED with SUBSCRIBED selection option (RFC 5258 Section 3)
// =========================================================================

/// LIST-EXTENDED with SUBSCRIBED selection option should return only
/// mailboxes the user has subscribed to.
///
/// RFC 5258 Section 3: The SUBSCRIBED selection option requests that the
/// server return only mailboxes that the user has subscribed to.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_subscribed_filter() {
    let conn = connect_dovecot().await;

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

    // Subscribe to only one of the two mailboxes.
    conn.subscribe(mbox_sub, TIMEOUT).await.unwrap();

    // LIST-EXTENDED with SUBSCRIBED selection option.
    let subscribed = conn
        .list_extended("", &["*"], &["SUBSCRIBED"], &[], TIMEOUT)
        .await
        .unwrap();

    // The subscribed mailbox should appear in the results.
    let found_sub = subscribed.iter().any(|mb| mb.name.as_str() == mbox_sub);
    assert!(
        found_sub,
        "subscribed mailbox '{mbox_sub}' should appear in SUBSCRIBED LIST results"
    );

    // The unsubscribed mailbox should NOT appear (unless the server is
    // non-conformant — Dovecot may still include it with \NonExistent
    // attribute removed, but it should at least have \Subscribed on the
    // subscribed one).
    let sub_entry = subscribed.iter().find(|mb| mb.name.as_str() == mbox_sub);
    if let Some(entry) = sub_entry {
        let has_subscribed = entry
            .attributes
            .iter()
            .any(|a| matches!(a, daaki_imap::types::MailboxAttribute::Subscribed));
        assert!(
            has_subscribed,
            "subscribed mailbox should have \\Subscribed attribute"
        );
    }

    // Clean up
    conn.unsubscribe(mbox_sub, TIMEOUT).await.unwrap();
    conn.delete(mbox_sub, TIMEOUT).await.unwrap();
    conn.delete(mbox_nosub, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `LIST (SUBSCRIBED RECURSIVEMATCH)` must return an unsubscribed parent when
/// it has a subscribed descendant, exposing that relationship via CHILDINFO.
///
/// RFC 5258 Section 3.1 defines `RECURSIVEMATCH`, and RFC 5258 Section 3.5
/// defines the `CHILDINFO` extended data item used to describe the matching
/// child relationship.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_subscribed_recursive_match_returns_parent_childinfo() {
    let conn = connect_dovecot().await;

    let parent = unique_name("test-list-recursivematch-parent");
    let child = format!("{parent}/child");
    conn.create(&parent, TIMEOUT).await.unwrap();
    conn.create(&child, TIMEOUT).await.unwrap();
    conn.subscribe(&child, TIMEOUT).await.unwrap();

    let listing = conn
        .list_extended(
            "",
            &[&parent],
            &["SUBSCRIBED", "RECURSIVEMATCH"],
            &["CHILDREN"],
            TIMEOUT,
        )
        .await
        .unwrap();

    let parent_entry = listing
        .iter()
        .find(|info| info.name.as_str() == parent)
        .unwrap_or_else(|| panic!("expected parent mailbox in LIST results: {listing:?}"));
    assert!(
        !parent_entry
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::Subscribed)),
        "parent must remain unsubscribed and be returned only via RECURSIVEMATCH: {:?}",
        parent_entry.attributes
    );
    assert!(
        parent_entry
            .child_info
            .iter()
            .any(|item| item.eq_ignore_ascii_case("SUBSCRIBED")),
        "parent returned via RECURSIVEMATCH must advertise CHILDINFO (SUBSCRIBED): {:?}",
        parent_entry.child_info
    );
    assert!(
        parent_entry
            .attributes
            .iter()
            .any(|attr| matches!(attr, MailboxAttribute::HasChildren)),
        "parent mailbox with a child must report \\HasChildren when RETURN (CHILDREN) is requested: {:?}",
        parent_entry.attributes
    );
    assert!(
        !listing.iter().any(|info| info.name.as_str() == child),
        "exact parent pattern must not return the subscribed child itself: {listing:?}"
    );

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

// =========================================================================
// MULTIAPPEND with explicit internal dates (RFC 3502)
// =========================================================================

/// MULTIAPPEND with per-message internal dates verifies that the server
/// honors the provided dates.
///
/// RFC 3502 Section 3: Each message in a MULTIAPPEND can carry its own
/// flags and date-time. RFC 3501 Section 6.3.11: If a date-time is
/// specified, the server SHOULD set the internal date to the specified
/// value.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_with_dates() {
    let conn = connect_dovecot().await;

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

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

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

    let mut msg1 = daaki_imap::types::AppendMessage::new(msg1_data.to_vec());
    msg1.flags = vec![Flag::Seen];
    msg1.date = Some("15-Mar-2020 10:00:00 +0000".to_owned());

    let mut msg2 = daaki_imap::types::AppendMessage::new(msg2_data.to_vec());
    msg2.flags = vec![Flag::Flagged];
    msg2.date = Some("20-Jun-2021 14:30:00 +0000".to_owned());

    let result = conn
        .multi_append(mbox, &[msg1, msg2], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(result.len(), 2, "expected 2 APPENDUID entries");

    // Verify the internal dates were honored by fetching them back.
    conn.select(mbox, TIMEOUT).await.unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::InternalDate, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "expected 2 fetched messages");

    // Verify the first message has the 2020 date.
    let f1 = &fetches[0];
    let date1 = f1
        .internal_date
        .as_ref()
        .expect("INTERNALDATE should be present");
    assert!(
        date1.contains("2020"),
        "first message's INTERNALDATE should reflect 2020, got: {date1}"
    );

    // Verify the second message has the 2021 date.
    let f2 = &fetches[1];
    let date2 = f2
        .internal_date
        .as_ref()
        .expect("INTERNALDATE should be present");
    assert!(
        date2.contains("2021"),
        "second message's INTERNALDATE should reflect 2021, got: {date2}"
    );

    // Verify flags were preserved.
    let flags1 = f1.flags.as_ref().expect("FLAGS should be present");
    assert!(
        flags1.contains(&Flag::Seen),
        "first message should have \\Seen flag"
    );
    let flags2 = f2.flags.as_ref().expect("FLAGS should be present");
    assert!(
        flags2.contains(&Flag::Flagged),
        "second message should have \\Flagged flag"
    );

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

/// MULTIAPPEND must keep each message's flag list isolated.
///
/// RFC 3502 Section 3 defines each appended message as carrying its own
/// optional flag list, and RFC 3501 Section 2.3.2 defines both system flags
/// and keywords as message flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_preserves_per_message_flags_and_keywords() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-multiappend-flags");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let mut msg1 = AppendMessage::new(
        b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multiappend flags 1\r\n\
Date: Sun, 15 Mar 2026 00:00:01 +0000\r\n\
Message-ID: <multiappend-flags-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
First multiappend flags message.\r\n"
            .to_vec(),
    );
    msg1.flags = vec![Flag::Seen, Flag::Custom("$DaakiMultiOne".to_owned())];

    let mut msg2 = AppendMessage::new(
        b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multiappend flags 2\r\n\
Date: Sun, 15 Mar 2026 00:00:02 +0000\r\n\
Message-ID: <multiappend-flags-2@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Second multiappend flags message.\r\n"
            .to_vec(),
    );
    msg2.flags = vec![
        Flag::Flagged,
        Flag::Answered,
        Flag::Custom("$DaakiMultiTwo".to_owned()),
    ];

    let mut msg3 = AppendMessage::new(
        b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multiappend flags 3\r\n\
Date: Sun, 15 Mar 2026 00:00:03 +0000\r\n\
Message-ID: <multiappend-flags-3@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Third multiappend flags message.\r\n"
            .to_vec(),
    );
    msg3.flags = vec![Flag::Draft];

    conn.multi_append(&mailbox, &[msg1, msg2, msg3], TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 3, "expected three appended messages");

    let flags1 = fetches[0]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(flags1.contains(&Flag::Seen));
    assert!(flags1.contains(&Flag::Custom("$DaakiMultiOne".to_owned())));
    assert!(!flags1.contains(&Flag::Flagged));
    assert!(!flags1.contains(&Flag::Custom("$DaakiMultiTwo".to_owned())));

    let flags2 = fetches[1]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(flags2.contains(&Flag::Flagged));
    assert!(flags2.contains(&Flag::Answered));
    assert!(flags2.contains(&Flag::Custom("$DaakiMultiTwo".to_owned())));
    assert!(!flags2.contains(&Flag::Seen));
    assert!(!flags2.contains(&Flag::Custom("$DaakiMultiOne".to_owned())));

    let flags3 = fetches[2]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(flags3.contains(&Flag::Draft));
    assert!(!flags3.contains(&Flag::Seen));
    assert!(!flags3.contains(&Flag::Flagged));
    assert!(!flags3.contains(&Flag::Custom("$DaakiMultiOne".to_owned())));
    assert!(!flags3.contains(&Flag::Custom("$DaakiMultiTwo".to_owned())));

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

/// MULTIAPPEND must filter `\\*` independently for each appended message while
/// preserving the valid flags that were supplied for that message.
///
/// RFC 3501 Section 7.1 defines `\\*` as the permanent-flag marker rather than
/// a storable message flag, and RFC 3502 Section 3 applies APPEND flag-list
/// semantics separately to each message in the batch.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_filters_server_only_flags_per_message() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-multiappend-filter-flags");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let mut msg1 = AppendMessage::new(TEST_MSG.to_vec());
    msg1.flags = vec![
        Flag::Wildcard,
        Flag::Seen,
        Flag::Custom("$DaakiFilterOne".to_owned()),
    ];

    let mut msg2 = AppendMessage::new(TEST_MSG.to_vec());
    msg2.flags = vec![Flag::Wildcard, Flag::Flagged];

    conn.multi_append(&mailbox, &[msg1, msg2], TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "expected two appended messages");

    let flags1 = fetches[0]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(flags1.contains(&Flag::Seen), "\\Seen must be preserved");
    assert!(
        flags1.contains(&Flag::Custom("$DaakiFilterOne".to_owned())),
        "message 1 keyword must be preserved"
    );
    assert!(
        !flags1.contains(&Flag::Wildcard),
        "\\* must not be sent as a MULTIAPPEND flag"
    );

    let flags2 = fetches[1]
        .flags
        .as_ref()
        .expect("FETCH FLAGS must be present when requested");
    assert!(
        flags2.contains(&Flag::Flagged),
        "\\Flagged must be preserved"
    );
    assert!(
        !flags2.contains(&Flag::Wildcard),
        "\\* must not be sent for later MULTIAPPEND messages either"
    );

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

/// MULTIAPPEND to a different mailbox while selected must not change the
/// currently selected mailbox.
///
/// RFC 3502 Section 3 permits MULTIAPPEND in selected state, and a successful
/// append batch must not implicitly reselect or unselect the current mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_to_other_mailbox_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    let selected_mailbox = prepare_selected_mailbox(&conn, "test-multiappend-selected-src").await;
    let target_mailbox = unique_name("test-multiappend-selected-dst");
    conn.create(&target_mailbox, TIMEOUT).await.unwrap();

    let messages = vec![
        AppendMessage::new(TEST_MSG.to_vec()),
        AppendMessage::new(TEST_MSG.to_vec()),
    ];
    conn.multi_append(&target_mailbox, &messages, TIMEOUT)
        .await
        .unwrap();

    assert_selected_mailbox_still_usable(&conn, "MULTIAPPEND").await;

    conn.select(&target_mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1, 2],
        "MULTIAPPEND must store each literal in the destination mailbox"
    );

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

/// MULTIAPPEND must apply INTERNALDATE independently per message.
///
/// RFC 3502 Section 3 allows each message in the batch to carry its own
/// optional internal date, and RFC 3501 Section 6.3.11 defines that date as
/// the appended message's INTERNALDATE.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_multi_append_mixed_dates_do_not_bleed_between_messages() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-multiappend-date-isolation");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let mut msg1 = AppendMessage::new(
        b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multiappend date 1\r\n\
Date: Sun, 15 Mar 2026 00:00:01 +0000\r\n\
Message-ID: <multiappend-date-1@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
First multiappend date message.\r\n"
            .to_vec(),
    );
    msg1.date = Some("01-Jan-1999 01:02:03 +0000".to_owned());

    let msg2 = AppendMessage::new(
        b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multiappend date 2\r\n\
Date: Sun, 15 Mar 2026 00:00:02 +0000\r\n\
Message-ID: <multiappend-date-2@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Second multiappend date message.\r\n"
            .to_vec(),
    );

    let mut msg3 = AppendMessage::new(
        b"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Multiappend date 3\r\n\
Date: Sun, 15 Mar 2026 00:00:03 +0000\r\n\
Message-ID: <multiappend-date-3@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Third multiappend date message.\r\n"
            .to_vec(),
    );
    msg3.date = Some("02-Feb-2000 04:05:06 +0000".to_owned());

    conn.multi_append(&mailbox, &[msg1, msg2, msg3], TIMEOUT)
        .await
        .unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::InternalDate],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 3, "expected three appended messages");

    let date1 = fetches[0]
        .internal_date
        .as_deref()
        .expect("message 1 must have INTERNALDATE");
    assert!(
        date1.contains("1999"),
        "message 1 INTERNALDATE must reflect its explicit date, got: {date1}"
    );

    let date2 = fetches[1]
        .internal_date
        .as_deref()
        .expect("message 2 must have INTERNALDATE");
    assert!(
        !date2.contains("1999") && !date2.contains("2000"),
        "message 2 INTERNALDATE must not inherit another message's explicit date, got: {date2}"
    );

    let date3 = fetches[2]
        .internal_date
        .as_deref()
        .expect("message 3 must have INTERNALDATE");
    assert!(
        date3.contains("2000"),
        "message 3 INTERNALDATE must reflect its explicit date, got: {date3}"
    );

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

// =========================================================================
// UID COPY flag preservation (RFC 3501 Section 6.4.7)
// =========================================================================

/// UID COPY should preserve message flags in the destination mailbox.
///
/// RFC 3501 Section 6.4.7: The COPY command copies the specified message(s)
/// to the end of the specified destination mailbox. The flags and internal
/// date of the message(s) SHOULD be preserved.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_copy_preserves_flags() {
    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();

    // Append a message with specific flags.
    conn.append(src, &[Flag::Seen, Flag::Flagged], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // SELECT source and get UID.
    conn.select(src, TIMEOUT).await.unwrap();
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least 1 message in src");
    let uid = fetches[0].uid.expect("UID must be present");

    // UID COPY to destination.
    let copy_result = conn
        .uid_copy(&SequenceSet::new(uid.to_string()).unwrap(), dst, TIMEOUT)
        .await
        .unwrap();
    // Dovecot supports UIDPLUS, so we should get a COPYUID response code.
    assert!(
        matches!(
            copy_result.code,
            Some(daaki_imap::types::ResponseCode::CopyUid { .. })
        ),
        "COPYUID response code should be present, got: {:?}",
        copy_result.code
    );

    // SELECT destination and verify flags were preserved.
    conn.select(dst, TIMEOUT).await.unwrap();
    let dst_fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(dst_fetches.len(), 1, "expected 1 message in dst");

    let dst_flags = dst_fetches[0]
        .flags
        .as_ref()
        .expect("FLAGS should be present");
    assert!(
        dst_flags.contains(&Flag::Seen),
        "copied message should preserve \\Seen flag"
    );
    assert!(
        dst_flags.contains(&Flag::Flagged),
        "copied message should preserve \\Flagged flag"
    );

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

// =========================================================================
// UID EXPUNGE scoping (RFC 4315 Section 2)
// =========================================================================

/// UID EXPUNGE must ignore matching UIDs that are not marked `\\Deleted`.
///
/// RFC 4315 Section 2: UID EXPUNGE permanently removes only messages that both
/// have the `\\Deleted` flag set and have a UID in the specified sequence set.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_expunge_ignores_non_deleted_uids() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-uid-expunge-ignore-nondeleted");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let first_uid = fetches[0].uid.expect("first message must have UID");
    let second_uid = fetches[1].uid.expect("second message must have UID");

    conn.uid_store(
        &SequenceSet::new(first_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.uid_expunge(
        &SequenceSet::new(format!("{first_uid},{second_uid}")).unwrap(),
        TIMEOUT,
    )
    .await
    .unwrap();

    let remaining = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        remaining.len(),
        1,
        "UID EXPUNGE must not remove non-deleted messages even when their UID is requested"
    );
    assert_eq!(remaining[0].uid, Some(second_uid));

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

/// UID EXPUNGE must leave deleted messages untouched when their UID is not in
/// the requested set.
///
/// RFC 4315 Section 2: UID EXPUNGE removes only messages whose UID is in the
/// given set, so a deleted message outside that set must remain in the mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_expunge_limits_to_requested_deleted_uid_set() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-uid-expunge-scoped");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

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

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let first_uid = fetches[0].uid.expect("first message must have UID");
    let second_uid = fetches[1].uid.expect("second message must have UID");

    conn.uid_store(
        &SequenceSet::new(format!("{first_uid},{second_uid}")).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.uid_expunge(&SequenceSet::new(first_uid.to_string()).unwrap(), TIMEOUT)
        .await
        .unwrap();

    let remaining = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        remaining.len(),
        1,
        "UID EXPUNGE must remove only the requested deleted message"
    );
    assert_eq!(remaining[0].uid, Some(second_uid));
    assert!(
        remaining[0]
            .flags
            .as_ref()
            .expect("remaining message must include FLAGS")
            .contains(&Flag::Deleted),
        "the unrequested deleted message must remain deleted after UID EXPUNGE"
    );

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

/// UID EXPUNGE with a nonexistent UID must be a no-op, even if other messages
/// in the mailbox are marked `\\Deleted`.
///
/// RFC 4315 Section 2 scopes removal to UIDs in the specified set, so a
/// nonexistent UID cannot cause a different deleted message to be expunged.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_expunge_nonexistent_uid_is_noop() {
    let conn = connect_dovecot().await;

    let mailbox = unique_name("test-uid-expunge-missing");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

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

    let existing_uid = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()[0]
        .uid
        .expect("appended message must have UID");
    let missing_uid = existing_uid + 1000;

    conn.uid_store(
        &SequenceSet::new(existing_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    conn.uid_expunge(&SequenceSet::new(missing_uid.to_string()).unwrap(), TIMEOUT)
        .await
        .unwrap();

    let remaining = conn
        .uid_fetch(
            &SequenceSet::new(existing_uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        remaining.len(),
        1,
        "UID EXPUNGE with a nonexistent UID must not expunge other deleted messages"
    );
    assert_eq!(remaining[0].uid, Some(existing_uid));
    assert!(
        remaining[0]
            .flags
            .as_ref()
            .expect("remaining message must include FLAGS")
            .contains(&Flag::Deleted),
        "the deleted message must remain present after UID EXPUNGE for a nonexistent UID"
    );

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

// =========================================================================
// CONDSTORE: MODSEQ in FETCH after STORE (RFC 7162 Section 3.1.3)
// =========================================================================

/// When CONDSTORE is active, STORE responses must include MODSEQ in the
/// implicit FETCH that reports the updated flags.
///
/// RFC 7162 Section 3.1.3: If the server supports CONDSTORE, it MUST
/// include MODSEQ fetch data in all STORE responses.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_returns_modseq_with_condstore() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

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

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

    // SELECT with CONDSTORE to activate mod-sequence tracking.
    conn.select_with(mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    // Non-silent STORE — the response should include MODSEQ.
    let store_result = conn
        .store(
            &SequenceSet::new("1").unwrap(),
            StoreOperation::Add,
            &[Flag::Answered],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();

    // The implicit FETCH in the STORE response should carry MODSEQ.
    assert!(
        !store_result.fetches.is_empty(),
        "non-silent STORE should return implicit FETCH responses"
    );
    for f in &store_result.fetches {
        assert!(
            f.mod_seq.is_some(),
            "STORE response with CONDSTORE active must include MODSEQ, got: {f:?}"
        );
    }

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

// =========================================================================
// CONDSTORE: STORE with stale UNCHANGEDSINCE (RFC 7162 Section 3.1.3)
// =========================================================================

/// STORE with a stale UNCHANGEDSINCE value must report MODIFIED in the
/// response code.
///
/// RFC 7162 Section 3.1.3: If the mod-sequence of a message is greater
/// than the UNCHANGEDSINCE value, the server MUST NOT modify the message
/// flags and MUST include the MODIFIED response code.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_store_unchanged_since_conflict() {
    use daaki_imap::types::{ResponseCode, SelectOptions};

    let conn = connect_dovecot().await;

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

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

    // SELECT with CONDSTORE.
    let selected = conn
        .select_with(mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();
    let initial_modseq = selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ must be present with CONDSTORE");

    // First STORE to advance the mod-sequence.
    conn.uid_store(
        &SequenceSet::new("1:*").unwrap(),
        StoreOperation::Add,
        &[Flag::Seen],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Second STORE with stale unchanged_since — the message's modseq is
    // now > initial_modseq, so this should trigger a MODIFIED response.
    let result = conn
        .uid_store(
            &SequenceSet::new("1:*").unwrap(),
            StoreOperation::Add,
            &[Flag::Flagged],
            Some(initial_modseq),
            TIMEOUT,
        )
        .await
        .unwrap();

    // RFC 7162 Section 3.1.3: the server MUST include the MODIFIED
    // response code when the message's mod-sequence exceeds the
    // UNCHANGEDSINCE threshold.
    let modified_uids = match &result.code {
        Some(ResponseCode::Modified(uids)) => uids,
        other => panic!(
            "RFC 7162 Section 3.1.3: server MUST return MODIFIED when \
             message modseq > UNCHANGEDSINCE threshold, got: {other:?}"
        ),
    };
    assert!(
        !modified_uids.is_empty(),
        "MODIFIED response should include at least one UID range"
    );

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

// =========================================================================
// UID FETCH VANISHED — QRESYNC (RFC 7162 Section 3.2.6)
// =========================================================================

/// RFC 7162 Section 3.2.6: UID FETCH with VANISHED modifier reports UIDs
/// of messages expunged since the given mod-sequence value.
///
/// This test ENABLEs QRESYNC, appends messages, expunges one, then re-selects
/// with QRESYNC and calls `uid_fetch_vanished` to confirm the expunged UID
/// appears in the VANISHED response.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_vanished_after_expunge() {
    use daaki_imap::types::{QresyncParams, SelectOptions};

    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::QResync)) {
        eprintln!("skipping: Dovecot does not advertise QRESYNC");
        conn.logout().await.unwrap();
        return;
    }

    // RFC 7162 Section 3.2.3: QRESYNC must be ENABLEd before use.
    conn.enable(&["QRESYNC"], TIMEOUT).await.unwrap();

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

    // SELECT with CONDSTORE to get initial state.
    let selected = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();
    let uid_validity = selected.uid_validity.expect("UIDVALIDITY must be present");

    // Append 3 messages.
    for _ in 0..3 {
        conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
            .await
            .unwrap();
    }

    // Re-select to get fresh HIGHESTMODSEQ after appends.
    let selected = conn
        .select_with(&mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();
    let modseq_before_expunge = selected
        .highest_mod_seq
        .expect("HIGHESTMODSEQ should be present with CONDSTORE");

    // Discover UIDs.
    let search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(search.ids.len(), 3, "expected 3 messages");
    let mut uids = search.ids.clone();
    uids.sort_unstable();

    // Delete and expunge the second message.
    let del_set = SequenceSet::new(uids[1].to_string()).unwrap();
    conn.uid_store(
        &del_set,
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.expunge(TIMEOUT).await.unwrap();

    // Close and re-select with QRESYNC using the old modseq.
    conn.close(TIMEOUT).await.unwrap();

    let params = QresyncParams::new(uid_validity, modseq_before_expunge);
    let _reselected = conn
        .select_with(&mbox, &SelectOptions::qresync(params), TIMEOUT)
        .await
        .unwrap();

    // RFC 7162 Section 3.2.6: UID FETCH 1:* with VANISHED should report
    // the expunged UID.
    let fetch_set = SequenceSet::new("1:*").unwrap();
    let (_fetches, vanished) = conn
        .uid_fetch_vanished(
            &fetch_set,
            &[FetchAttr::Uid, FetchAttr::Flags],
            modseq_before_expunge,
            TIMEOUT,
        )
        .await
        .unwrap();

    // The vanished list should contain the UID we expunged.
    let expunged_uid = uids[1];
    let found_in_vanished = vanished.iter().any(|r| {
        let end = r.end.unwrap_or(r.start);
        expunged_uid >= r.start && expunged_uid <= end
    });
    assert!(
        found_in_vanished,
        "vanished list should contain expunged UID {expunged_uid}; got: {vanished:?}",
    );

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

// =========================================================================
// SORT / UID SORT (RFC 5256 Section 2)
// =========================================================================

/// `SORT` must return current sequence numbers in ascending date order, even
/// when those sequence numbers no longer match the messages' stable UIDs.
///
/// RFC 5256 Section 2 defines `SORT` in sequence-number space, while RFC 3501
/// Section 2.3.1.1 keeps UIDs stable across expunges.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_sort_returns_sequence_numbers_in_date_order() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-sort-seq");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    create_uid_gap(&conn, &mbox).await;

    let live_uids = append_sort_fixture(&conn, &mbox, "sort-seq").await;
    assert_ne!(
        live_uids,
        [1, 2, 3],
        "setup must create a UID/sequence divergence before SORT"
    );

    let sort_result = conn.sort("DATE", "UTF-8", "ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        sort_result.ids,
        vec![2, 1, 3],
        "SORT DATE must return sequence numbers in ascending date order"
    );
    assert!(
        !sort_result.truncated,
        "complete SORT results must not be marked truncated"
    );

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

/// `SORT REVERSE DATE` must invert the requested date ordering while still
/// returning sequence numbers for the selected mailbox.
///
/// RFC 5256 Section 2 defines `REVERSE` as the inverse of the requested sort
/// order and keeps the non-UID variant in sequence-number space.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_sort_reverse_date_returns_sequence_numbers_descending() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-sort-seq-reverse");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    create_uid_gap(&conn, &mbox).await;

    let live_uids = append_sort_fixture(&conn, &mbox, "sort-seq-reverse").await;
    assert_ne!(
        live_uids,
        [1, 2, 3],
        "setup must create a UID/sequence divergence before SORT"
    );

    let sort_result = conn
        .sort("REVERSE DATE", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        sort_result.ids,
        vec![3, 1, 2],
        "SORT REVERSE DATE must return sequence numbers in descending date order"
    );
    assert!(
        !sort_result.truncated,
        "complete SORT results must not be marked truncated"
    );

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

/// `UID SORT` must return stable UIDs in ascending date order rather than the
/// current sequence numbers of the selected mailbox.
///
/// RFC 5256 Section 2 defines `UID SORT` as the UID-space variant of `SORT`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_sort_returns_uids_in_date_order() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-sort-direct");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    create_uid_gap(&conn, &mbox).await;

    let [uid_mar, uid_jan, uid_jun] = append_sort_fixture(&conn, &mbox, "uid-sort-direct").await;

    let sort_result = conn
        .uid_sort("DATE", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        sort_result.ids,
        vec![uid_jan, uid_mar, uid_jun],
        "UID SORT DATE must return UIDs in ascending date order"
    );
    assert!(
        !sort_result.truncated,
        "complete UID SORT results must not be marked truncated"
    );

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

/// `UID SORT REVERSE DATE` must return stable UIDs in descending date order.
///
/// RFC 5256 Section 2 defines `REVERSE` as the inverse of the requested sort
/// order for the UID-space variant as well.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_sort_reverse_date_returns_uids_descending() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-sort-direct-reverse");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    create_uid_gap(&conn, &mbox).await;

    let [uid_mar, uid_jan, uid_jun] =
        append_sort_fixture(&conn, &mbox, "uid-sort-direct-reverse").await;

    let sort_result = conn
        .uid_sort("REVERSE DATE", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        sort_result.ids,
        vec![uid_jun, uid_mar, uid_jan],
        "UID SORT REVERSE DATE must return UIDs in descending date order"
    );
    assert!(
        !sort_result.truncated,
        "complete UID SORT results must not be marked truncated"
    );

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

/// `SORT` on an empty mailbox must return an empty result set.
///
/// RFC 5256 Section 2 defines `SORT` as the sorted set of matching messages,
/// which is empty when no message matches.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_sort_empty_mailbox_returns_empty() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-sort-direct-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let sort_result = conn.sort("DATE", "UTF-8", "ALL", TIMEOUT).await.unwrap();
    assert!(
        sort_result.ids.is_empty(),
        "SORT on an empty mailbox must return no message numbers"
    );
    assert!(
        !sort_result.truncated,
        "empty SORT results must not be marked truncated"
    );

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

/// `UID SORT` on an empty mailbox must return an empty UID set.
///
/// RFC 5256 Section 2 defines the UID variant as the empty UID set when no
/// message matches the search criteria.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_sort_empty_mailbox_returns_empty() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-sort-direct-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let sort_result = conn
        .uid_sort("DATE", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert!(
        sort_result.ids.is_empty(),
        "UID SORT on an empty mailbox must return no UIDs"
    );
    assert!(
        !sort_result.truncated,
        "empty UID SORT results must not be marked truncated"
    );

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

// =========================================================================
// Pipeline SORT (RFC 5256 Section 2)
// =========================================================================

/// RFC 5256 Section 2: SORT command orders messages by the specified key.
/// Verifies DATE sort returns messages in chronological order.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_sort_by_date() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Sort)) {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

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

    // Append 3 messages with different dates (out of chronological order).
    let dates = [
        "15 Mar 2026 00:00:00 +0000",
        "10 Jan 2026 00:00:00 +0000",
        "20 Jun 2026 00:00:00 +0000",
    ];
    for (i, date) in dates.iter().enumerate() {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Sort test {i}\r\n\
             Date: {date}\r\n\
             Message-ID: <sort-date-{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",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    // Re-select to see all messages.
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Pipeline: SORT by DATE ascending.
    let (sort_result,) = conn
        .pipeline()
        .sort("DATE".to_string(), "UTF-8".to_string(), "ALL".to_string())
        .execute()
        .await
        .unwrap();

    let sort_result = sort_result.unwrap();

    // RFC 5256 Section 2: SORT returns message numbers in order.
    // Date order: Jan (msg 2) < Mar (msg 1) < Jun (msg 3).
    // Sequence numbers are 1-based in append order: msg1=1, msg2=2, msg3=3.
    // Sorted by date: seq 2 (Jan), seq 1 (Mar), seq 3 (Jun).
    assert_eq!(
        sort_result.ids.len(),
        3,
        "SORT should return 3 message numbers"
    );
    assert_eq!(
        sort_result.ids,
        vec![2, 1, 3],
        "SORT DATE should order: Jan(2) < Mar(1) < Jun(3)"
    );

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

/// RFC 5256 Section 2: REVERSE modifier reverses the sort order.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_sort_reverse() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Sort)) {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

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

    let dates = [
        "15 Mar 2026 00:00:00 +0000",
        "10 Jan 2026 00:00:00 +0000",
        "20 Jun 2026 00:00:00 +0000",
    ];
    for (i, date) in dates.iter().enumerate() {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Sort rev test {i}\r\n\
             Date: {date}\r\n\
             Message-ID: <sort-rev-{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",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    // Pipeline: SORT by REVERSE DATE.
    let (sort_result,) = conn
        .pipeline()
        .sort(
            "REVERSE DATE".to_string(),
            "UTF-8".to_string(),
            "ALL".to_string(),
        )
        .execute()
        .await
        .unwrap();

    let sort_result = sort_result.unwrap();

    // Reverse date order: Jun(3) > Mar(1) > Jan(2).
    assert_eq!(sort_result.ids.len(), 3, "SORT should return 3 messages");
    assert_eq!(
        sort_result.ids,
        vec![3, 1, 2],
        "SORT REVERSE DATE should order: Jun(3) > Mar(1) > Jan(2)"
    );

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

// =========================================================================
// Pipeline THREAD (RFC 5256 Section 3)
// =========================================================================

/// RFC 5256 Section 3: THREAD REFERENCES groups related messages into
/// threaded trees using In-Reply-To and References headers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_thread_references() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    let has_thread_refs = caps
        .iter()
        .any(|c| matches!(c, Capability::Thread(algo) if algo.eq_ignore_ascii_case("REFERENCES")));
    if !has_thread_refs {
        eprintln!("skipping: Dovecot does not advertise THREAD=REFERENCES");
        conn.logout().await.unwrap();
        return;
    }

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

    let pid = std::process::id();

    // Original message.
    let original = format!(
        "From: alice@example.com\r\n\
         To: bob@example.com\r\n\
         Subject: Thread test\r\n\
         Date: 10 Jan 2026 00:00:00 +0000\r\n\
         Message-ID: <thread-orig-{pid}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Original message\r\n"
    );
    conn.append(&mbox, &[], None, original.as_bytes(), TIMEOUT)
        .await
        .unwrap();

    // Reply 1.
    let reply1 = format!(
        "From: bob@example.com\r\n\
         To: alice@example.com\r\n\
         Subject: Re: Thread test\r\n\
         Date: 11 Jan 2026 00:00:00 +0000\r\n\
         Message-ID: <thread-reply1-{pid}@daaki>\r\n\
         In-Reply-To: <thread-orig-{pid}@daaki>\r\n\
         References: <thread-orig-{pid}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Reply 1\r\n"
    );
    conn.append(&mbox, &[], None, reply1.as_bytes(), TIMEOUT)
        .await
        .unwrap();

    // Reply 2 (to original).
    let reply2 = format!(
        "From: charlie@example.com\r\n\
         To: alice@example.com\r\n\
         Subject: Re: Thread test\r\n\
         Date: 12 Jan 2026 00:00:00 +0000\r\n\
         Message-ID: <thread-reply2-{pid}@daaki>\r\n\
         In-Reply-To: <thread-orig-{pid}@daaki>\r\n\
         References: <thread-orig-{pid}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Reply 2\r\n"
    );
    conn.append(&mbox, &[], None, reply2.as_bytes(), TIMEOUT)
        .await
        .unwrap();

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

    // Pipeline: THREAD REFERENCES.
    let (threads,) = conn
        .pipeline()
        .thread(
            "REFERENCES".to_string(),
            "UTF-8".to_string(),
            "ALL".to_string(),
        )
        .execute()
        .await
        .unwrap();

    let threads = threads.unwrap();

    // RFC 5256 Section 3: All three messages share a thread via References.
    // We expect at least one thread node that has children.
    assert!(
        !threads.is_empty(),
        "THREAD REFERENCES should return at least one thread"
    );

    // Count total messages across all thread trees.
    let total = {
        fn count_nodes(nodes: &[ThreadNode]) -> usize {
            nodes
                .iter()
                .map(|n| usize::from(n.id.is_some()) + count_nodes(&n.children))
                .sum()
        }
        count_nodes(&threads)
    };
    assert_eq!(total, 3, "all 3 messages should appear in the thread tree");

    // At least one node should have children (the original has 2 replies).
    let has_children = threads.iter().any(|n| !n.children.is_empty());
    assert!(
        has_children,
        "at least one thread node should have children (replies)"
    );

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

// =========================================================================
// Pipeline UID SEARCH + UID FETCH (RFC 3501 Sections 6.4.4, 6.4.5)
// =========================================================================

/// Pipeline UID SEARCH on two different criteria verifies correct result
/// routing when multiple search consumers are active in a single batch.
///
/// RFC 3501 Section 6.4.4 (SEARCH), RFC 4731 Section 3.1 (ESEARCH).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_search_two_criteria() {
    let conn = connect_dovecot().await;

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

    // Append 3 messages: first two with \Seen, third without.
    for i in 0..3 {
        let flags = if i < 2 { vec![Flag::Seen] } else { vec![] };
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Search2 test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <search2-{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",
            std::process::id()
        );
        conn.append(&mbox, &flags, None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    // Pipeline: UID SEARCH ALL + UID SEARCH SEEN.
    let (all_result, seen_result) = conn
        .pipeline()
        .uid_search("ALL".to_string())
        .uid_search("SEEN".to_string())
        .execute()
        .await
        .unwrap();

    let all = all_result.unwrap().unwrap();
    let seen = seen_result.unwrap().unwrap();

    assert_eq!(all.ids.len(), 3, "UID SEARCH ALL should find 3 messages");
    assert_eq!(seen.ids.len(), 2, "UID SEARCH SEEN should find 2 messages");

    // Every SEEN UID should be in the ALL set.
    for uid in &seen.ids {
        assert!(
            all.ids.contains(uid),
            "SEEN UID {uid} should appear in ALL results"
        );
    }

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

// =========================================================================
// Pipeline STORE + FETCH (RFC 3501 Sections 6.4.6, 6.4.5)
// =========================================================================

/// Pipeline UID STORE.SILENT on two different UIDs in a single batch
/// verifies correct consumer routing for multiple STORE commands.
///
/// RFC 3501 Section 6.4.6 (STORE): `.SILENT` suppresses implicit FETCH
/// responses, so this tests pure command-level pipelining.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_store_silent_two_uids() {
    let conn = connect_dovecot().await;

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

    // Append 2 messages.
    for i in 0..2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Store pipeline test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <store-pipe-{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",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    // Discover UIDs.
    let search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(search.ids.len(), 2);
    let mut uids = search.ids.clone();
    uids.sort_unstable();

    let set1 = SequenceSet::new(uids[0].to_string()).unwrap();
    let set2 = SequenceSet::new(uids[1].to_string()).unwrap();

    // Pipeline: STORE.SILENT \Flagged on uid[0] + STORE.SILENT \Seen on uid[1].
    let (store1, store2) = conn
        .pipeline()
        .uid_store(set1, StoreOperation::AddSilent, vec![Flag::Flagged], None)
        .uid_store(set2, StoreOperation::AddSilent, vec![Flag::Seen], None)
        .execute()
        .await
        .unwrap();

    store1.unwrap();
    store2.unwrap();

    // Verify via standalone fetch that the flags were applied.
    let all_set = SequenceSet::new("1:*").unwrap();
    let fetches = conn
        .uid_fetch(&all_set, &[FetchAttr::Uid, FetchAttr::Flags], TIMEOUT)
        .await
        .unwrap();

    assert_eq!(fetches.len(), 2, "expected 2 messages");

    let f0 = fetches.iter().find(|f| f.uid == Some(uids[0])).unwrap();
    let f1 = fetches.iter().find(|f| f.uid == Some(uids[1])).unwrap();

    let flags0 = f0.flags.as_ref().unwrap();
    assert!(
        flags0.contains(&Flag::Flagged),
        "uid[0] should have \\Flagged; got: {flags0:?}"
    );

    let flags1 = f1.flags.as_ref().unwrap();
    assert!(
        flags1.contains(&Flag::Seen),
        "uid[1] should have \\Seen; got: {flags1:?}"
    );

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

// =========================================================================
// Pipeline COPY + EXPUNGE (RFC 3501 Section 6.4.7, RFC 3501 Section 6.4.3)
// =========================================================================

/// Pipeline UID COPY + EXPUNGE in a single batch: copies a message to
/// the destination, then expunges from the source.
///
/// RFC 3501 Section 6.4.7 (COPY), RFC 3501 Section 6.4.3 (EXPUNGE).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_copy_and_expunge() {
    let conn = connect_dovecot().await;

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

    // Append a message to source and select it.
    conn.append(&src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    // Discover UID and mark as \Deleted for expunge.
    let search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(search.ids.len(), 1);
    let uid = search.ids[0];
    let uid_set = SequenceSet::new(uid.to_string()).unwrap();

    // Mark as deleted so EXPUNGE removes it.
    conn.uid_store(
        &uid_set,
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    let copy_set = SequenceSet::new(uid.to_string()).unwrap();
    let dst_name = MailboxName::new(&dst).unwrap();

    // Pipeline: UID COPY to dst + EXPUNGE.
    let (copy_result, expunge_result) = conn
        .pipeline()
        .uid_copy(copy_set, dst_name)
        .expunge()
        .execute()
        .await
        .unwrap();

    let _copy = copy_result.unwrap();
    let _expunge = expunge_result.unwrap();

    // Verify source is empty.
    conn.select(&src, TIMEOUT).await.unwrap();
    let src_search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert!(
        src_search.ids.is_empty(),
        "source mailbox should be empty after expunge; got {} messages",
        src_search.ids.len()
    );

    // Verify destination has the message.
    conn.select(&dst, TIMEOUT).await.unwrap();
    let dst_search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        dst_search.ids.len(),
        1,
        "destination mailbox should have 1 message after copy"
    );

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

// =========================================================================
// Pipeline CREATE + DELETE + RENAME (RFC 3501 Sections 6.3.3, 6.3.4, 6.3.5)
// =========================================================================

/// Pipeline mailbox lifecycle: CREATE two mailboxes, then RENAME one and
/// DELETE the other.
///
/// RFC 3501 Section 6.3.3 (CREATE), RFC 3501 Section 6.3.4 (DELETE),
/// RFC 3501 Section 6.3.5 (RENAME).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_create_delete_rename() {
    let conn = connect_dovecot().await;

    let pid = std::process::id();
    let mbox1 = format!("test-pipe-cdr-1-{pid}");
    let mbox2 = format!("test-pipe-cdr-2-{pid}");
    let mbox3 = format!("test-pipe-cdr-3-{pid}");

    let name1 = MailboxName::new(&mbox1).unwrap();
    let name2 = MailboxName::new(&mbox2).unwrap();

    // Pipeline: CREATE mbox1 + CREATE mbox2.
    let (create1, create2) = conn
        .pipeline()
        .create(name1)
        .create(name2)
        .execute()
        .await
        .unwrap();

    create1.unwrap();
    create2.unwrap();

    // Pipeline: RENAME mbox1 -> mbox3 + DELETE mbox2.
    let rename_from = MailboxName::new(&mbox1).unwrap();
    let rename_to = MailboxName::new(&mbox3).unwrap();
    let del_name = MailboxName::new(&mbox2).unwrap();

    let (rename_result, delete_result) = conn
        .pipeline()
        .rename(rename_from, rename_to)
        .delete(del_name)
        .execute()
        .await
        .unwrap();

    rename_result.unwrap();
    delete_result.unwrap();

    // Verify: mbox3 should exist, mbox1 and mbox2 should not.
    let list = conn.list("", "*", TIMEOUT).await.unwrap();
    let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();

    assert!(
        names.contains(&mbox3.as_str()),
        "mbox3 should exist after rename; list: {names:?}"
    );
    assert!(
        !names.contains(&mbox1.as_str()),
        "mbox1 should not exist after rename; list: {names:?}"
    );
    assert!(
        !names.contains(&mbox2.as_str()),
        "mbox2 should not exist after delete; list: {names:?}"
    );

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

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

/// Pipeline subscribe lifecycle: SUBSCRIBE, verify with LSUB, then
/// UNSUBSCRIBE and verify removal.
///
/// RFC 3501 Section 6.3.6 (SUBSCRIBE), RFC 3501 Section 6.3.7 (UNSUBSCRIBE),
/// RFC 3501 Section 6.3.9 (LSUB).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_subscribe_lsub_unsubscribe() {
    let conn = connect_dovecot().await;

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

    let sub_name = MailboxName::new(&mbox).unwrap();

    // Pipeline: SUBSCRIBE.
    let (sub_result,) = conn.pipeline().subscribe(sub_name).execute().await.unwrap();
    sub_result.unwrap();

    // Pipeline: LSUB to verify subscription.
    let (lsub_result,) = conn
        .pipeline()
        .lsub(String::new(), mbox.clone())
        .execute()
        .await
        .unwrap();
    let subscribed = lsub_result.unwrap();
    assert!(
        subscribed.iter().any(|m| m.name.as_str() == mbox),
        "mailbox should appear in LSUB after SUBSCRIBE; got: {subscribed:?}"
    );

    // Pipeline: UNSUBSCRIBE.
    let unsub_name = MailboxName::new(&mbox).unwrap();
    let (unsub_result,) = conn
        .pipeline()
        .unsubscribe(unsub_name)
        .execute()
        .await
        .unwrap();
    unsub_result.unwrap();

    // Pipeline: LSUB to verify unsubscription.
    let (lsub_result2,) = conn
        .pipeline()
        .lsub(String::new(), mbox.clone())
        .execute()
        .await
        .unwrap();
    let unsubscribed = lsub_result2.unwrap();
    assert!(
        !unsubscribed.iter().any(|m| m.name.as_str() == mbox),
        "mailbox should not appear in LSUB after UNSUBSCRIBE; got: {unsubscribed:?}"
    );

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

// =========================================================================
// Pipeline ACL (RFC 4314 Sections 3.1, 3.3, 3.5)
// =========================================================================

/// Pipeline ACL lifecycle: SETACL + GETACL + MYRIGHTS in a single batch.
///
/// RFC 4314 Section 3.1 (SETACL), RFC 4314 Section 3.3 (GETACL),
/// RFC 4314 Section 3.5 (MYRIGHTS).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_acl_lifecycle() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Acl)) {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

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

    let set_name = MailboxName::new(&mbox).unwrap();
    let get_name = MailboxName::new(&mbox).unwrap();
    let rights_name = MailboxName::new(&mbox).unwrap();

    // Pipeline: SETACL + GETACL + MYRIGHTS.
    let (set_result, acl_entries, my_rights) = conn
        .pipeline()
        .set_acl(set_name, USER.to_string(), "lrswipkxtecda".to_string())
        .get_acl(get_name)
        .my_rights(rights_name)
        .execute()
        .await
        .unwrap();

    set_result.unwrap();
    let entries = acl_entries.unwrap();
    let rights = my_rights.unwrap();

    // RFC 4314 Section 3.3: GETACL should return an entry for the user.
    assert!(
        entries.iter().any(|e| e.identifier == USER),
        "ACL entries should include {USER}; got: {entries:?}"
    );

    // RFC 4314 Section 3.5: MYRIGHTS should return a non-empty rights string.
    assert!(
        !rights.is_empty(),
        "MYRIGHTS should return non-empty rights for mailbox owner"
    );

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

// =========================================================================
// Pipeline QUOTA (RFC 2087 Sections 4.2, 4.3)
// =========================================================================

/// Pipeline GETQUOTAROOT on INBOX to discover quota roots and resources.
///
/// RFC 2087 Section 4.3 (GETQUOTAROOT), RFC 2087 Section 4.2 (GETQUOTA).
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_quota() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Quota)) {
        eprintln!("skipping: Dovecot does not advertise QUOTA");
        conn.logout().await.unwrap();
        return;
    }

    let inbox_name = MailboxName::new("INBOX").unwrap();

    // Pipeline: GETQUOTAROOT on INBOX.
    let (quota_root_result,) = conn
        .pipeline()
        .get_quota_root(inbox_name)
        .execute()
        .await
        .unwrap();

    let quota_root = quota_root_result.unwrap();

    // RFC 2087 Section 4.3: QUOTAROOT returns the root names.
    // Dovecot typically returns at least one root for INBOX.
    // If quota is not configured, roots may be empty — that's acceptable.
    if !quota_root.roots.is_empty() {
        // If we have a root, try GETQUOTA on it.
        let root_name = quota_root.roots[0].clone();
        let (quota_result,) = conn
            .pipeline()
            .get_quota(root_name)
            .execute()
            .await
            .unwrap();

        let resources = quota_result.unwrap();
        // If quota is configured, resources should be non-empty.
        // We just verify the call succeeds — quota configuration varies.
        let _ = resources;
    }

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

/// GETQUOTAROOT accepts a mailbox name that does not yet exist.
///
/// RFC 9208 Section 4.1.2: the mailbox parameter "doesn't have to reference
/// an existing mailbox".
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota_root_nonexistent_mailbox_succeeds() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Quota))
    {
        eprintln!("skipping: Dovecot does not advertise QUOTA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-quota-root-nonexistent");
    let quota_root = conn.get_quota_root(&mailbox, TIMEOUT).await.unwrap();

    for root in &quota_root.roots {
        assert!(
            quota_root.resources.iter().any(|(name, _)| name == root),
            "GETQUOTAROOT must include QUOTA data for each returned root; roots={:?} resources={:?}",
            quota_root.roots,
            quota_root.resources
        );
    }

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

/// GETQUOTAROOT must return exactly one QUOTA block for each listed root.
///
/// RFC 9208 Section 4.1.2 requires `QUOTAROOT` and `QUOTA`; RFC 9208
/// Section 4.2.1 defines each QUOTA response as describing one quota root.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota_root_reports_resources_for_each_listed_root() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Quota))
    {
        eprintln!("skipping: Dovecot does not advertise QUOTA");
        conn.logout().await.unwrap();
        return;
    }

    let quota_root = conn.get_quota_root("INBOX", TIMEOUT).await.unwrap();
    let roots: BTreeSet<_> = quota_root.roots.iter().cloned().collect();
    let resource_roots: BTreeSet<_> = quota_root
        .resources
        .iter()
        .map(|(root, _)| root.clone())
        .collect();

    assert_eq!(
        roots.len(),
        quota_root.roots.len(),
        "GETQUOTAROOT must not repeat quota roots: {:?}",
        quota_root.roots
    );
    assert_eq!(
        resource_roots.len(),
        quota_root.resources.len(),
        "GETQUOTAROOT must not return duplicate QUOTA blocks: {:?}",
        quota_root.resources
    );
    assert_eq!(
        resource_roots, roots,
        "GETQUOTAROOT must return one QUOTA block for each listed root"
    );

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

/// GETQUOTA must agree with the QUOTA data returned by GETQUOTAROOT.
///
/// RFC 9208 Section 4.1.1 and Section 4.1.2 both return QUOTA data for a
/// named quota root, so the typed resource lists must match.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota_matches_get_quota_root_resources() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Quota))
    {
        eprintln!("skipping: Dovecot does not advertise QUOTA");
        conn.logout().await.unwrap();
        return;
    }

    let quota_root = conn.get_quota_root("INBOX", TIMEOUT).await.unwrap();
    if quota_root.roots.is_empty() {
        eprintln!("skipping: Dovecot reported no quota roots for INBOX");
        conn.logout().await.unwrap();
        return;
    }

    for root in &quota_root.roots {
        let direct = conn.get_quota(root, TIMEOUT).await.unwrap();
        let from_quota_root = quota_root
            .resources
            .iter()
            .find(|(name, _)| name == root)
            .expect("GETQUOTAROOT must include resources for each listed root");
        let direct_map = quota_resource_map(&direct);
        let quota_root_map = quota_resource_map(&from_quota_root.1);

        assert_eq!(
            direct_map.len(),
            direct.len(),
            "GETQUOTA must not repeat resource names for quota root {root:?}; got {direct:?}"
        );
        assert_eq!(
            quota_root_map.len(),
            from_quota_root.1.len(),
            "GETQUOTAROOT must not repeat resource names for quota root {root:?}; got {:?}",
            from_quota_root.1
        );

        assert_eq!(
            direct_map, quota_root_map,
            "GETQUOTA and GETQUOTAROOT must agree for quota root {root:?}"
        );
    }

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

// =========================================================================
// Pipeline UID MOVE (RFC 6851 Section 3)
// =========================================================================

/// Pipeline UID MOVE: moves messages from source to destination mailbox.
///
/// RFC 6851 Section 3: UID MOVE atomically copies and expunges.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_move_messages() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Move)) {
        eprintln!("skipping: Dovecot does not advertise MOVE");
        conn.logout().await.unwrap();
        return;
    }

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

    // Append 2 messages to source.
    for i in 0..2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Move test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <move-{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",
            std::process::id()
        );
        conn.append(&src, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    conn.select(&src, TIMEOUT).await.unwrap();

    // Discover UIDs.
    let search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(search.ids.len(), 2, "expected 2 messages in source");

    let move_set = SequenceSet::new("1:*").unwrap();
    let dst_name = MailboxName::new(&dst).unwrap();

    // Pipeline: UID MOVE to dest.
    let (move_result,) = conn
        .pipeline()
        .uid_move_messages(move_set, dst_name)
        .execute()
        .await
        .unwrap();

    let _move_res = move_result.unwrap();

    // RFC 6851 Section 3: After MOVE, source should be empty.
    // Need to re-select since MOVE triggers EXPUNGE which changes state.
    conn.select(&src, TIMEOUT).await.unwrap();
    let src_search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert!(
        src_search.ids.is_empty(),
        "source mailbox should be empty after MOVE; got {} messages",
        src_search.ids.len()
    );

    // Destination should have the 2 messages.
    conn.select(&dst, TIMEOUT).await.unwrap();
    let dst_search = conn.uid_search("ALL", TIMEOUT).await.unwrap();
    assert_eq!(
        dst_search.ids.len(),
        2,
        "destination mailbox should have 2 messages after MOVE"
    );

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

// =========================================================================
// APPENDUID verification (RFC 4315 Section 3)
// =========================================================================

/// APPEND must return APPENDUID response code with valid uid_validity and uid
/// when the server supports UIDPLUS (RFC 4315 Section 3).
///
/// RFC 4315 Section 3: If the server supports UIDPLUS, an OK response to
/// APPEND MUST include an APPENDUID response code containing the
/// UIDVALIDITY and the UID assigned to the new message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_append_returns_appenduid() {
    let conn = connect_dovecot().await;

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

    // Dovecot supports UIDPLUS, so append must return Some((uid_validity, uid)).
    let result = conn
        .append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    assert!(
        result.is_some(),
        "UIDPLUS server must return APPENDUID, got None"
    );
    let (uid_validity, uid) = result.unwrap();
    assert!(uid_validity > 0, "UIDVALIDITY must be positive");
    assert!(uid > 0, "assigned UID must be positive");

    // Verify the assigned UID by fetching it.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "must be able to fetch by the returned UID"
    );
    assert_eq!(
        fetches[0].uid.unwrap(),
        uid,
        "fetched UID must match APPENDUID"
    );

    // Append a second message and verify it gets a different, higher UID.
    let result2 = conn
        .append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let (uid_validity2, uid2) = result2.unwrap();
    assert_eq!(
        uid_validity, uid_validity2,
        "UIDVALIDITY must not change within a session"
    );
    assert!(
        uid2 > uid,
        "second APPENDUID ({uid2}) must be greater than first ({uid})"
    );

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

// =========================================================================
// COPYUID field verification (RFC 4315 Section 3)
// =========================================================================

/// COPY must return COPYUID response code with valid uid_validity,
/// source_uids, and dest_uids when the server supports UIDPLUS.
///
/// RFC 4315 Section 3: An OK response to COPY/UID COPY MUST include a
/// COPYUID response code that contains the UIDVALIDITY of the destination,
/// the UID set of the source messages, and the UID set assigned in the
/// destination.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_returns_copyuid_with_valid_fields() {
    let conn = connect_dovecot().await;

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

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

    // Get source UIDs.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2);
    let src_uid1 = fetches[0].uid.unwrap();
    let src_uid2 = fetches[1].uid.unwrap();

    // UID COPY both messages.
    let copy_result = conn
        .uid_copy(
            &SequenceSet::new(format!("{src_uid1}:{src_uid2}")).unwrap(),
            dst,
            TIMEOUT,
        )
        .await
        .unwrap();

    // Verify COPYUID response code fields.
    match copy_result.code {
        Some(ResponseCode::CopyUid {
            uid_validity,
            source_uids,
            dest_uids,
        }) => {
            assert!(uid_validity > 0, "destination UIDVALIDITY must be positive");
            assert!(!source_uids.is_empty(), "source_uids must not be empty");
            assert!(!dest_uids.is_empty(), "dest_uids must not be empty");
            // The source UIDs should cover the 2 messages we copied.
            let src_uid_count: u32 = source_uids
                .iter()
                .map(|r| r.end.map_or(1, |e| e - r.start + 1))
                .sum();
            assert_eq!(
                src_uid_count, 2,
                "source_uids must cover exactly 2 messages"
            );
            // The dest UIDs should also cover 2 messages.
            let dst_uid_count: u32 = dest_uids
                .iter()
                .map(|r| r.end.map_or(1, |e| e - r.start + 1))
                .sum();
            assert_eq!(dst_uid_count, 2, "dest_uids must cover exactly 2 messages");
        }
        other => panic!("expected COPYUID response code, got: {other:?}"),
    }

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

// =========================================================================
// MOVE returns COPYUID + EXPUNGE (RFC 6851 Section 4.3)
// =========================================================================

/// MOVE must return MoveResult with both COPYUID response code and
/// expunged message data.
///
/// RFC 6851 Section 3: The MOVE command is logically COPY + EXPUNGE.
/// The tagged OK response SHOULD include a COPYUID response code
/// (RFC 4315). Expunge notifications for the moved messages are included
/// in the MoveResult.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_move_returns_copyuid_and_expunged() {
    let conn = connect_dovecot().await;

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

    // Append a message and get its UID.
    conn.append(src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1);
    let src_uid = fetches[0].uid.unwrap();

    // UID MOVE the message.
    let move_result = conn
        .uid_move_messages(
            &SequenceSet::new(src_uid.to_string()).unwrap(),
            dst,
            TIMEOUT,
        )
        .await
        .unwrap();

    // RFC 6851 Section 4.3: Servers SHOULD include COPYUID response code.
    // When present, verify its structure.
    if let Some(ResponseCode::CopyUid {
        uid_validity,
        source_uids,
        dest_uids,
    }) = &move_result.code
    {
        assert!(*uid_validity > 0, "COPYUID uid_validity must be positive");
        assert!(
            !source_uids.is_empty(),
            "COPYUID source_uids must not be empty"
        );
        assert!(!dest_uids.is_empty(), "COPYUID dest_uids must not be empty");
    }
    // Note: COPYUID is SHOULD, not MUST — code may be None.

    // Verify expunged data is present.
    match &move_result.expunged {
        daaki_imap::types::ExpungeResult::Expunged(seqs) => {
            assert!(
                !seqs.is_empty(),
                "MOVE must report at least one EXPUNGE for the moved message"
            );
        }
        daaki_imap::types::ExpungeResult::Vanished(ranges) => {
            // QRESYNC variant — also valid.
            assert!(
                !ranges.is_empty(),
                "MOVE with QRESYNC must report VANISHED for the moved message"
            );
        }
        other => {
            // ExpungeResult is #[non_exhaustive] so a wildcard arm is required,
            // but MOVE must always report expunges — fail loudly on unknown variants.
            panic!("unexpected ExpungeResult variant from MOVE: {other:?}");
        }
    }

    // Source should be empty, destination should have the message.
    let selected_src = conn.select(src, TIMEOUT).await.unwrap();
    assert_eq!(selected_src.exists, 0, "source must be empty after MOVE");

    let selected_dst = conn.select(dst, TIMEOUT).await.unwrap();
    assert!(
        selected_dst.exists >= 1,
        "destination must have the moved message"
    );

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

// =========================================================================
// ESEARCH ALL return option (RFC 4731 Section 3.1)
// =========================================================================

/// ESEARCH with ALL return option must return the matching UID set.
///
/// RFC 4731 Section 3.1: ALL — returns all matching message numbers/UIDs
/// as a uid-set. This complements MIN/MAX/COUNT tests.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_esearch_all_return_option() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-esearch-all-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Append 3 messages, mark the 2nd as \Seen.
    for i in 0..3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: ESEARCH ALL test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <esearch-all-{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",
            std::process::id()
        );
        let flags = if i == 1 { vec![Flag::Seen] } else { vec![] };
        conn.append(&mbox, &flags, None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    // ESEARCH ALL with COUNT return option — should match all 3.
    let esearch = conn
        .uid_search_esearch("ALL", &["ALL", "COUNT"], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(esearch.count, Some(3), "COUNT should be 3 for SEARCH ALL");
    // ALL field contains the matching UIDs as ranges.
    assert!(
        !esearch.all.is_empty(),
        "ALL return data must contain at least one range for 3 messages"
    );
    // Expand the ranges and verify we get 3 UIDs.
    let uid_count: u32 = esearch
        .all
        .iter()
        .map(|r| r.end.map_or(1, |e| e - r.start + 1))
        .sum();
    assert_eq!(uid_count, 3, "ALL ranges must cover exactly 3 UIDs");

    // ESEARCH UNSEEN with ALL — should match 2 messages.
    let esearch_unseen = conn
        .uid_search_esearch("UNSEEN", &["ALL", "COUNT"], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(esearch_unseen.count, Some(2));
    let unseen_count: u32 = esearch_unseen
        .all
        .iter()
        .map(|r| r.end.map_or(1, |e| e - r.start + 1))
        .sum();
    assert_eq!(unseen_count, 2, "ALL ranges for UNSEEN must cover 2 UIDs");

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

// =========================================================================
// SEARCH with MODSEQ criterion (RFC 7162 Section 3.1.5)
// =========================================================================

/// UID SEARCH with MODSEQ criterion returns matching UIDs and the
/// highest mod-sequence of those messages.
///
/// RFC 7162 Section 3.1.5: A SEARCH/UID SEARCH command that includes
/// the MODSEQ search-key MUST return the highest mod-sequence for all
/// messages being returned.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_modseq_criterion() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-search-modseq-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();

    // Enable CONDSTORE via SELECT (CONDSTORE).
    let selected = conn
        .select_with(
            &mbox,
            &daaki_imap::types::SelectOptions::condstore(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(
        selected.highest_mod_seq.is_some(),
        "CONDSTORE-enabled SELECT must return HIGHESTMODSEQ"
    );

    // Append and store a flag to create a known mod-sequence.
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::ModSeq],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty());
    let uid = fetches[0].uid.unwrap();
    let initial_modseq = fetches[0]
        .mod_seq
        .expect("MODSEQ should be present with CONDSTORE");

    // Store a flag to bump the mod-sequence.
    conn.uid_store(
        &SequenceSet::new(uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Flagged],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // Search with MODSEQ — should find messages changed since initial_modseq.
    let search_result = conn
        .uid_search(&format!("MODSEQ {initial_modseq}"), TIMEOUT)
        .await
        .unwrap();
    assert!(
        !search_result.ids.is_empty(),
        "SEARCH MODSEQ must find the message we just flagged"
    );
    assert!(
        search_result.mod_seq.is_some(),
        "SEARCH MODSEQ response must include the highest mod-sequence (RFC 7162 §3.1.5)"
    );

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

// =========================================================================
// FETCH with partial byte range (RFC 3501 Section 6.4.5)
// =========================================================================

/// FETCH BODY[TEXT]<0.N> returns only the first N bytes of the body.
///
/// RFC 3501 Section 6.4.5: A substring of the body can be requested
/// via `<partial-start.partial-length>`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_body_section_partial() {
    let conn = connect_dovecot().await;

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

    let body_text = b"This is the body text for partial fetch testing. It has enough content to make partial ranges meaningful.\r\n";
    let msg = format!(
        "From: sender@example.com\r\n\
         To: recipient@example.com\r\n\
         Subject: Partial fetch test\r\n\
         Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
         Message-ID: <partial-fetch-{}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         {}",
        std::process::id(),
        std::str::from_utf8(body_text).unwrap()
    );
    conn.append(mbox, &[], None, msg.as_bytes(), TIMEOUT)
        .await
        .unwrap();

    // Fetch full body text first to know the total size.
    let full_fetch = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::BodySection {
                peek: true,
                section: Some("TEXT".to_owned()),
                partial: None,
            }],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(full_fetch.len(), 1);
    let full_section = full_fetch[0]
        .body_sections
        .first()
        .expect("body section should be present");
    let full_body = full_section
        .data
        .as_ref()
        .expect("body section data should not be NIL");
    let full_len = full_body.len();
    assert!(full_len > 20, "body must be long enough for partial test");

    // Fetch only first 20 bytes.
    let partial_fetch = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::BodySection {
                peek: true,
                section: Some("TEXT".to_owned()),
                partial: Some((0, 20)),
            }],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(partial_fetch.len(), 1);
    let partial_section = partial_fetch[0]
        .body_sections
        .first()
        .expect("partial body section should be present");
    let partial_body = partial_section
        .data
        .as_ref()
        .expect("partial body data should not be NIL");
    assert_eq!(
        partial_body.len(),
        20,
        "partial fetch should return exactly 20 bytes, got {}",
        partial_body.len()
    );
    // The partial bytes must match the beginning of the full body.
    assert_eq!(
        &partial_body[..],
        &full_body[..20],
        "partial bytes must match the start of the full body"
    );
    // Partial fetch should report the origin offset.
    assert_eq!(
        partial_section.origin,
        Some(0),
        "partial fetch origin should be 0"
    );

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

// =========================================================================
// Pipeline ESEARCH (RFC 4731 via pipeline)
// =========================================================================

/// ESEARCH via pipeline — both search_return and uid_search_return.
///
/// RFC 4731 Section 3.2: SEARCH/UID SEARCH with RETURN options.
/// Tests that the pipeline builder correctly batches ESEARCH commands
/// and returns typed EsearchResponse results.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_esearch_return() {
    let conn = connect_dovecot().await;
    let mbox = format!("test-pipe-esearch-{}", std::process::id());
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Append 4 messages: 2 unseen, 2 seen.
    for i in 0..4 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Pipeline ESEARCH test {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <pipe-esearch-{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",
            std::process::id()
        );
        let flags = if i < 2 { vec![] } else { vec![Flag::Seen] };
        conn.append(&mbox, &flags, None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    // Pipeline: UID SEARCH RETURN ALL COUNT + SEARCH RETURN MIN MAX COUNT.
    let (uid_esearch_res, seq_esearch_res) = conn
        .pipeline()
        .uid_search_return("ALL".to_owned(), vec!["ALL".to_owned(), "COUNT".to_owned()])
        .search_return(
            "UNSEEN".to_owned(),
            vec!["MIN".to_owned(), "MAX".to_owned(), "COUNT".to_owned()],
        )
        .execute()
        .await
        .unwrap();

    // UID ESEARCH for ALL should return 4 messages.
    // Pipeline wraps in Result<Result<EsearchResponse, Error>, Error>.
    let uid_esearch = uid_esearch_res.unwrap().unwrap();
    assert_eq!(
        uid_esearch.count,
        Some(4),
        "UID ESEARCH ALL COUNT should be 4"
    );
    assert!(
        uid_esearch.uid,
        "UID SEARCH RETURN must set uid=true in response"
    );

    // Sequence ESEARCH for UNSEEN should return 2 messages.
    let seq_esearch = seq_esearch_res.unwrap().unwrap();
    assert_eq!(
        seq_esearch.count,
        Some(2),
        "SEARCH UNSEEN COUNT should be 2"
    );
    assert!(seq_esearch.min.is_some(), "MIN must be present");
    assert!(seq_esearch.max.is_some(), "MAX must be present");

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

/// `SEARCH RETURN (ALL ...)` must report sequence numbers, not UIDs, even when
/// the mailbox's current sequence numbers no longer match surviving UIDs.
///
/// RFC 4731 Section 3.1 defines `ALL` as the full set of matching message
/// numbers for `SEARCH RETURN`, while RFC 3501 Section 2.3.1.2 keeps sequence
/// numbers distinct from immutable UIDs.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_search_return_all_uses_sequence_numbers_after_uid_gap() {
    let conn = connect_dovecot().await;
    let mbox = unique_name("test-pipe-search-return-all-seq");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    create_uid_gap(&conn, &mbox).await;

    for label in ["seq-gap-a", "seq-gap-b"] {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {label}\r\n\
             Date: 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {label}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let live_uids: Vec<u32> = fetches
        .iter()
        .map(|fetch| fetch.uid.expect("FETCH must include UID"))
        .collect();
    assert_eq!(
        live_uids.len(),
        2,
        "expected 2 live messages after the UID gap"
    );

    let (search_result,) = conn
        .pipeline()
        .search_return(
            "ALL".to_owned(),
            vec![
                "MIN".to_owned(),
                "MAX".to_owned(),
                "COUNT".to_owned(),
                "ALL".to_owned(),
            ],
        )
        .execute()
        .await
        .unwrap();

    let search_result = search_result.unwrap().unwrap();
    assert!(
        !search_result.uid,
        "SEARCH RETURN must remain sequence-number based"
    );
    assert_eq!(search_result.count, Some(2), "COUNT must be 2");
    assert_eq!(search_result.min, Some(1), "MIN must be the first sequence");
    assert_eq!(search_result.max, Some(2), "MAX must be the last sequence");
    assert_eq!(
        expand_uid_ranges(&search_result.all),
        vec![1, 2],
        "SEARCH RETURN ALL must report sequence numbers"
    );
    assert_ne!(
        live_uids,
        vec![1, 2],
        "setup must create a UID/sequence-number divergence"
    );

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

/// `UID SEARCH RETURN (ALL ...)` must report UIDs, not sequence numbers, after
/// earlier messages have been expunged.
///
/// RFC 4731 Section 3.1 defines `ALL` as the matching UID set for `UID SEARCH
/// RETURN`, while RFC 3501 Section 2.3.1.1 requires UIDs to remain immutable.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_search_return_all_uses_uids_after_uid_gap() {
    let conn = connect_dovecot().await;
    let mbox = unique_name("test-pipe-uid-search-return-all");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    create_uid_gap(&conn, &mbox).await;

    for label in ["uid-gap-a", "uid-gap-b"] {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {label}\r\n\
             Date: 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {label}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    let live_uids: Vec<u32> = fetches
        .iter()
        .map(|fetch| fetch.uid.expect("FETCH must include UID"))
        .collect();
    assert_eq!(
        live_uids.len(),
        2,
        "expected 2 live messages after the UID gap"
    );

    let (search_result,) = conn
        .pipeline()
        .uid_search_return(
            "ALL".to_owned(),
            vec![
                "MIN".to_owned(),
                "MAX".to_owned(),
                "COUNT".to_owned(),
                "ALL".to_owned(),
            ],
        )
        .execute()
        .await
        .unwrap();

    let search_result = search_result.unwrap().unwrap();
    assert!(search_result.uid, "UID SEARCH RETURN must set uid=true");
    assert_eq!(search_result.count, Some(2), "COUNT must be 2");
    assert_eq!(
        search_result.min,
        Some(live_uids[0]),
        "MIN must be the first UID"
    );
    assert_eq!(
        search_result.max,
        Some(live_uids[1]),
        "MAX must be the last UID"
    );
    assert_eq!(
        expand_uid_ranges(&search_result.all),
        live_uids,
        "UID SEARCH RETURN ALL must report UIDs"
    );
    assert_ne!(
        expand_uid_ranges(&search_result.all),
        vec![1, 2],
        "UID SEARCH RETURN ALL must not fall back to sequence numbers"
    );

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

/// Pipeline `SEARCH RETURN` on an empty mailbox must report `COUNT=0` and omit
/// `MIN`, `MAX`, and `ALL`.
///
/// RFC 4731 Section 3.1: the result options describe the matching set; when no
/// message matches, the server must not synthesize message numbers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_search_return_empty_mailbox_all_min_max_absent() {
    let conn = connect_dovecot().await;
    let mbox = unique_name("test-pipe-search-return-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (search_result,) = conn
        .pipeline()
        .search_return(
            "ALL".to_owned(),
            vec![
                "MIN".to_owned(),
                "MAX".to_owned(),
                "COUNT".to_owned(),
                "ALL".to_owned(),
            ],
        )
        .execute()
        .await
        .unwrap();

    let search_result = search_result.unwrap().unwrap();
    assert!(
        !search_result.uid,
        "SEARCH RETURN must remain sequence-number based"
    );
    assert_eq!(search_result.count, Some(0), "COUNT must be zero");
    assert_eq!(search_result.min, None, "MIN must be absent");
    assert_eq!(search_result.max, None, "MAX must be absent");
    assert!(
        search_result.all.is_empty(),
        "ALL must be empty on an empty mailbox, got {:?}",
        search_result.all
    );

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

/// Pipeline `UID SEARCH RETURN` on an empty mailbox must report `COUNT=0` and
/// omit `MIN`, `MAX`, and `ALL`.
///
/// RFC 4731 Section 3.1: result options such as `MIN`, `MAX`, and `ALL`
/// describe actual matches and therefore must remain absent when no message
/// matches.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_search_return_empty_mailbox_all_min_max_absent() {
    let conn = connect_dovecot().await;
    let mbox = unique_name("test-pipe-uid-search-return-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (search_result,) = conn
        .pipeline()
        .uid_search_return(
            "ALL".to_owned(),
            vec![
                "MIN".to_owned(),
                "MAX".to_owned(),
                "COUNT".to_owned(),
                "ALL".to_owned(),
            ],
        )
        .execute()
        .await
        .unwrap();

    let search_result = search_result.unwrap().unwrap();
    assert!(search_result.uid, "UID SEARCH RETURN must set uid=true");
    assert_eq!(search_result.count, Some(0), "COUNT must be zero");
    assert_eq!(search_result.min, None, "MIN must be absent");
    assert_eq!(search_result.max, None, "MAX must be absent");
    assert!(
        search_result.all.is_empty(),
        "ALL must be empty on an empty mailbox, got {:?}",
        search_result.all
    );

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

// =========================================================================
// Pipeline SEARCH SAVE / UID SEARCH SAVE (RFC 5182 via pipeline)
// =========================================================================

/// Pipeline `SEARCH RETURN (SAVE)` followed by `FETCH $` must let the later
/// command use the saved sequence-set from the earlier command in the same
/// batch.
///
/// RFC 5182 Section 2: `$` names the result of the most recent SEARCH RETURN
/// (SAVE) command, so a pipelined `FETCH $` must operate on that saved result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_search_save_then_fetch_uses_saved_sequence_results() {
    let conn = connect_dovecot().await;
    if !supports_searchres(&conn) {
        eprintln!("skipping: Dovecot does not advertise SEARCHRES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-pipe-search-save-fetch-seq");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

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

    let (save_result, fetches) = conn
        .pipeline()
        .search_save("UNSEEN".to_owned())
        .fetch(
            SequenceSet::new("$").unwrap(),
            "(UID FLAGS)".to_owned(),
            None,
        )
        .execute()
        .await
        .unwrap();

    save_result.unwrap().unwrap();
    let fetches = fetches.unwrap();

    assert_eq!(
        fetches.len(),
        1,
        "FETCH $ must return exactly the saved unseen sequence result"
    );
    assert_eq!(fetches[0].seq, 2, "UNSEEN message should be sequence 2");
    assert!(
        fetches[0].uid.is_some(),
        "FETCH $ should include UID when requested"
    );
    assert!(
        !fetches[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Seen)),
        "FETCH $ must return the unseen message, not the seen one"
    );

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

/// Pipeline `UID SEARCH RETURN (SAVE)` followed by `UID FETCH $` must let the
/// later command use the saved UID set from the earlier command in the same
/// batch.
///
/// RFC 5182 Section 2: `$` names the result of the most recent UID SEARCH
/// RETURN (SAVE), so `UID FETCH $` must use that saved UID result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_search_save_then_uid_fetch_uses_saved_uid_results() {
    let conn = connect_dovecot().await;
    if !supports_searchres(&conn) {
        eprintln!("skipping: Dovecot does not advertise SEARCHRES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-pipe-search-save-fetch-uid");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    conn.append(&mbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let unseen_uid = conn
        .append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap()
        .expect("Dovecot APPEND should return APPENDUID")
        .1;

    let (save_result, fetch_result) = conn
        .pipeline()
        .uid_search_save("UNSEEN".to_owned())
        .uid_fetch(
            SequenceSet::new("$").unwrap(),
            "(UID FLAGS)".to_owned(),
            None,
            false,
        )
        .execute()
        .await
        .unwrap();

    save_result.unwrap().unwrap();
    let (fetches, vanished) = fetch_result.unwrap();

    assert!(
        vanished.is_empty(),
        "UID FETCH without VANISHED modifier must not report VANISHED data: {vanished:?}"
    );
    assert_eq!(
        fetches.len(),
        1,
        "UID FETCH $ must return exactly the saved unseen UID result"
    );
    assert_eq!(fetches[0].uid, Some(unseen_uid));
    assert!(
        !fetches[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Seen)),
        "UID FETCH $ must return the unseen UID, not the seen one"
    );

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

/// A second pipelined `SEARCH RETURN (SAVE)` must replace the earlier saved
/// result before later `$` references are evaluated.
///
/// RFC 5182 Section 2: the saved result is defined by the most recent SAVE
/// search, so `FETCH $` must reflect only the later search in the batch.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_search_save_overwrites_previous_saved_result() {
    let conn = connect_dovecot().await;
    if !supports_searchres(&conn) {
        eprintln!("skipping: Dovecot does not advertise SEARCHRES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-pipe-search-save-overwrite");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

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

    let (first_save, second_save, fetches) = conn
        .pipeline()
        .search_save("SEEN".to_owned())
        .search_save("FLAGGED".to_owned())
        .fetch(SequenceSet::new("$").unwrap(), "FLAGS".to_owned(), None)
        .execute()
        .await
        .unwrap();

    first_save.unwrap().unwrap();
    second_save.unwrap().unwrap();
    let fetches = fetches.unwrap();

    assert_eq!(
        fetches.len(),
        1,
        "the most recent SAVE search must replace the prior saved result"
    );
    assert_eq!(fetches[0].seq, 2, "FLAGGED message should be sequence 2");
    assert!(
        fetches[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Flagged)),
        "FETCH $ must reflect the later FLAGGED save result"
    );

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

/// Pipeline `SEARCH RETURN (SAVE)` followed by `STORE $` must apply the flag
/// update only to the saved result set.
///
/// RFC 5182 Section 2: `$` can be used by subsequent commands that accept a
/// sequence-set, so `STORE $` must target only the saved search result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_search_save_then_store_uses_saved_result() {
    let conn = connect_dovecot().await;
    if !supports_searchres(&conn) {
        eprintln!("skipping: Dovecot does not advertise SEARCHRES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-pipe-search-save-store");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

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

    let (save_result, store_result) = conn
        .pipeline()
        .search_save("UNSEEN".to_owned())
        .store(
            SequenceSet::new("$").unwrap(),
            StoreOperation::Add,
            vec![Flag::Flagged],
            None,
        )
        .execute()
        .await
        .unwrap();

    save_result.unwrap().unwrap();
    store_result.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.contains(&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 each saved unseen message"),
            other => panic!("unexpected sequence returned from FETCH: {other}"),
        }
    }

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

/// Pipeline `UID SEARCH RETURN (SAVE)` followed by `UID COPY $` must copy only
/// the saved UID result set to the destination mailbox.
///
/// RFC 5182 Section 2: `$` can be used by subsequent UID commands that accept
/// a UID set, so `UID COPY $` must copy exactly the saved UID result.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_search_save_then_uid_copy_uses_saved_result() {
    let conn = connect_dovecot().await;
    if !supports_searchres(&conn) {
        eprintln!("skipping: Dovecot does not advertise SEARCHRES");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-pipe-search-save-copy-src");
    let dst = unique_name("test-pipe-search-save-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_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.append(&src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    let (save_result, copy_result) = conn
        .pipeline()
        .uid_search_save("UNSEEN".to_owned())
        .uid_copy(
            SequenceSet::new("$").unwrap(),
            MailboxName::new(&dst).unwrap(),
        )
        .execute()
        .await
        .unwrap();

    save_result.unwrap().unwrap();
    copy_result.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 UID"
    );

    let copied = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        copied.len(),
        1,
        "destination mailbox should contain exactly one copied message"
    );
    assert!(
        !copied[0]
            .flags
            .as_ref()
            .is_some_and(|flags| flags.contains(&Flag::Seen)),
        "copied message must 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();
}

/// Pipeline `SEARCH RETURN (SAVE)` with an empty result set must leave `$`
/// empty for later commands in the same batch.
///
/// RFC 5182 Section 2: `$` denotes the saved result of the most recent SAVE
/// search, so when that result is empty a subsequent `FETCH $` must return no
/// rows.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_search_save_empty_result_fetch_returns_empty() {
    let conn = connect_dovecot().await;
    if !supports_searchres(&conn) {
        eprintln!("skipping: Dovecot does not advertise SEARCHRES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-pipe-search-save-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

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

    let (save_result, fetches) = conn
        .pipeline()
        .search_save("DELETED".to_owned())
        .fetch(SequenceSet::new("$").unwrap(), "FLAGS".to_owned(), None)
        .execute()
        .await
        .unwrap();

    save_result.unwrap().unwrap();
    let fetches = fetches.unwrap();
    assert!(
        fetches.is_empty(),
        "FETCH $ must be empty when the saved SEARCH result is empty: {fetches:?}"
    );

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

// =========================================================================
// Pipeline metadata get/set/delete (RFC 5464 via pipeline)
// =========================================================================

/// Metadata operations via pipeline — set then get in a single batch.
///
/// RFC 5464 Section 4.2/4.3: GETMETADATA and SETMETADATA commands.
/// Tests that these work correctly when pipelined together.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_metadata_set_and_get() {
    let conn = connect_dovecot().await;

    // Check that server supports METADATA.
    let has_metadata = conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Metadata));
    if !has_metadata {
        eprintln!("Dovecot does not advertise METADATA — skipping");
        conn.logout().await.unwrap();
        return;
    }

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

    // Pipeline: set metadata, then get it back.
    // Note: pipeline executes commands in order, so set completes before get.
    let (set_result, get_result) = conn
        .pipeline()
        .set_metadata(
            mbox.clone(),
            vec![(
                "/private/comment".to_owned(),
                Some(b"pipeline test value".to_vec()),
            )],
        )
        .get_metadata(
            mbox.clone(),
            vec!["/private/comment".to_owned()],
            None,
            None,
        )
        .execute()
        .await
        .unwrap();

    // Set should succeed (returns ()).
    set_result.unwrap();

    // Get should return the value we just set.
    let get_result = get_result.unwrap();
    assert!(
        !get_result.entries.is_empty(),
        "GETMETADATA must return at least one entry"
    );
    let entry = get_result
        .entries
        .iter()
        .find(|e| e.name == "/private/comment")
        .expect("/private/comment entry must be present");
    assert_eq!(
        entry.value.as_deref(),
        Some(b"pipeline test value".as_slice()),
        "metadata value must match what was set"
    );

    // Clean up: delete the metadata and the mailbox.
    let (delete_result,) = conn
        .pipeline()
        .set_metadata(mbox, vec![("/private/comment".to_owned(), None)])
        .execute()
        .await
        .unwrap();
    let _ = delete_result;

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

// =========================================================================
// Pipeline list_extended with RETURN CHILDREN (RFC 5258 Section 3)
// =========================================================================

/// LIST-EXTENDED via pipeline with RETURN (CHILDREN) option.
///
/// RFC 5258 Section 3: LIST with return options. The CHILDREN option
/// requests \HasChildren / \HasNoChildren attributes on every mailbox.
/// We verify the pipeline correctly passes these options through.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_extended_return_children() {
    let conn = connect_dovecot().await;

    // Use wildcard to match all mailboxes — INBOX always exists.
    let (list_result,) = conn
        .pipeline()
        .list_extended(
            vec![],
            String::new(),
            vec!["*".to_owned()],
            vec!["CHILDREN".to_owned()],
        )
        .execute()
        .await
        .unwrap();

    let mailboxes = list_result.unwrap().unwrap();
    assert!(!mailboxes.is_empty(), "LIST * must return at least INBOX");

    // RFC 5258 Section 3.4: with CHILDREN return option, every mailbox
    // must have either \HasChildren or \HasNoChildren.
    for mbox in &mailboxes {
        let has_child_attr = mbox.attributes.iter().any(|a| {
            matches!(
                a,
                MailboxAttribute::HasChildren | MailboxAttribute::HasNoChildren
            )
        });
        assert!(
            has_child_attr,
            "with CHILDREN return option, mailbox {:?} must have \\HasChildren or \\HasNoChildren, got: {:?}",
            mbox.name, mbox.attributes
        );
    }

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

// =========================================================================
// Multi-attribute FETCH (RFC 3501 Section 6.4.5)
// =========================================================================

/// Fetch with multiple data items in a single command — Envelope,
/// BodyStructure, InternalDate, Rfc822Size, Flags, and UID.
///
/// RFC 3501 Section 6.4.5: multiple fetch items can be requested
/// in a single FETCH command.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_multi_attr_combined() {
    let conn = connect_dovecot().await;

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

    let msg = b"\
From: alice@example.com\r\n\
To: bob@example.com\r\n\
Cc: carol@example.com\r\n\
Subject: Multi-attr fetch test\r\n\
Date: Mon, 10 Mar 2025 10:00:00 +0000\r\n\
Message-ID: <multi-attr-test@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Multi-attribute fetch body.\r\n";
    conn.append(mbox, &[Flag::Seen], None, msg, TIMEOUT)
        .await
        .unwrap();

    // Fetch all attributes at once.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Flags,
                FetchAttr::Envelope,
                FetchAttr::BodyStructure,
                FetchAttr::InternalDate,
                FetchAttr::Rfc822Size,
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "should fetch exactly 1 message");

    let resp = &fetches[0];
    // UID must be present.
    assert!(resp.uid.is_some(), "UID must be present");

    // Flags must include \Seen.
    let flags = resp.flags.as_ref().expect("FLAGS must be present");
    assert!(flags.contains(&Flag::Seen), "\\Seen flag must be present");

    // Envelope must have sender, recipient, subject.
    let env = resp.envelope.as_ref().expect("ENVELOPE must be present");
    assert_eq!(
        env.subject.as_deref(),
        Some("Multi-attr fetch test"),
        "envelope subject must match"
    );
    assert!(!env.from.is_empty(), "envelope FROM must not be empty");
    assert!(!env.to.is_empty(), "envelope TO must not be empty");
    assert!(!env.cc.is_empty(), "envelope CC must not be empty");

    // BodyStructure must be present.
    assert!(
        resp.body_structure.is_some(),
        "BODYSTRUCTURE must be present"
    );

    // InternalDate must be present and non-empty.
    assert!(resp.internal_date.is_some(), "INTERNALDATE must be present");

    // RFC822.SIZE must be present and > 0.
    let size = resp.rfc822_size.expect("RFC822.SIZE must be present");
    assert!(size > 0, "RFC822.SIZE must be > 0");

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

// =========================================================================
// SEARCH TEXT criterion (RFC 3501 Section 6.4.4)
// =========================================================================

/// SEARCH TEXT searches both headers and body, unlike BODY which only
/// searches the body.
///
/// RFC 3501 Section 6.4.4: TEXT — messages that contain the specified
/// string in the header OR body of the message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_text_vs_body() {
    let conn = connect_dovecot().await;

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

    let msg = b"\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: UniqueHeaderKeyword\r\n\
Date: Mon, 10 Mar 2025 10:00:00 +0000\r\n\
Message-ID: <search-text-test@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
UniqueBodyKeyword in the message body.\r\n";
    conn.append(mbox, &[], None, msg, TIMEOUT).await.unwrap();

    // TEXT should find the header keyword (since it searches headers + body).
    let text_header = conn
        .uid_search("TEXT UniqueHeaderKeyword", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        text_header.ids.len(),
        1,
        "TEXT must match keyword in Subject header"
    );

    // TEXT should also find the body keyword.
    let text_body = conn
        .uid_search("TEXT UniqueBodyKeyword", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(text_body.ids.len(), 1, "TEXT must match keyword in body");

    // BODY should find the body keyword.
    let body_body = conn
        .uid_search("BODY UniqueBodyKeyword", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(body_body.ids.len(), 1, "BODY must match keyword in body");

    // BODY should NOT find the header-only keyword (Subject is a header).
    // Note: Some servers may include headers in BODY search per their
    // interpretation. RFC 3501 says BODY searches "the body of the message"
    // which excludes headers, but behavior varies.
    let body_header = conn
        .uid_search("BODY UniqueHeaderKeyword", TIMEOUT)
        .await
        .unwrap();
    // Dovecot treats BODY as body-only (correct per RFC 3501).
    assert!(
        body_header.ids.is_empty(),
        "BODY should not match header-only keyword (RFC 3501: BODY searches message body, not headers)"
    );

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

// =========================================================================
// STORE multiple flags at once (RFC 3501 Section 6.4.6)
// =========================================================================

/// STORE with multiple flags set and removed in a single operation.
///
/// RFC 3501 Section 6.4.6: +FLAGS and -FLAGS can include multiple flags.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_store_multiple_flags_at_once() {
    let conn = connect_dovecot().await;

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

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

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

    // Add multiple flags at once.
    let store_result = conn
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Add,
            &[Flag::Seen, Flag::Flagged, Flag::Answered],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();

    // Verify all flags were applied (non-silent store returns implicit FETCH).
    assert_eq!(store_result.fetches.len(), 1);
    let updated_flags = store_result.fetches[0]
        .flags
        .as_ref()
        .expect("FLAGS must be present in STORE response");
    assert!(
        updated_flags.contains(&Flag::Seen),
        "\\Seen must be set after +FLAGS"
    );
    assert!(
        updated_flags.contains(&Flag::Flagged),
        "\\Flagged must be set after +FLAGS"
    );
    assert!(
        updated_flags.contains(&Flag::Answered),
        "\\Answered must be set after +FLAGS"
    );

    // Remove two of the three flags.
    let remove_result = conn
        .uid_store(
            &SequenceSet::new(uid.to_string()).unwrap(),
            StoreOperation::Remove,
            &[Flag::Flagged, Flag::Answered],
            None,
            TIMEOUT,
        )
        .await
        .unwrap();

    let remaining_flags = remove_result.fetches[0]
        .flags
        .as_ref()
        .expect("FLAGS must be present");
    assert!(
        remaining_flags.contains(&Flag::Seen),
        "\\Seen must still be set after removing other flags"
    );
    assert!(
        !remaining_flags.contains(&Flag::Flagged),
        "\\Flagged must be removed after -FLAGS"
    );
    assert!(
        !remaining_flags.contains(&Flag::Answered),
        "\\Answered must be removed after -FLAGS"
    );

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

// =========================================================================
// SEARCH with HEADER criterion (RFC 3501 Section 6.4.4)
// =========================================================================

/// SEARCH HEADER <field> <value> searches specific headers.
///
/// RFC 3501 Section 6.4.4: HEADER — matches messages with the specified
/// header field containing the specified string.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_header_criterion() {
    let conn = connect_dovecot().await;

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

    let msg1 = format!(
        "From: sender@example.com\r\n\
         To: recipient@example.com\r\n\
         Subject: Important project update\r\n\
         X-Custom-Tag: alpha\r\n\
         Date: Mon, 10 Mar 2025 10:00:00 +0000\r\n\
         Message-ID: <header-search-1-{}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Body 1.\r\n",
        std::process::id()
    );
    let msg2 = format!(
        "From: sender@example.com\r\n\
         To: recipient@example.com\r\n\
         Subject: Lunch plans\r\n\
         X-Custom-Tag: beta\r\n\
         Date: Tue, 11 Mar 2025 14:00:00 +0000\r\n\
         Message-ID: <header-search-2-{}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Body 2.\r\n",
        std::process::id()
    );

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

    // SEARCH HEADER X-Custom-Tag alpha — should match msg1 only.
    let result = conn
        .uid_search("HEADER X-Custom-Tag alpha", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        result.ids.len(),
        1,
        "HEADER X-Custom-Tag alpha should match 1 message"
    );

    // SEARCH HEADER X-Custom-Tag beta — should match msg2 only.
    let result = conn
        .uid_search("HEADER X-Custom-Tag beta", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        result.ids.len(),
        1,
        "HEADER X-Custom-Tag beta should match 1 message"
    );

    // SEARCH HEADER X-Custom-Tag nonexistent — should match nothing.
    let result = conn
        .uid_search("HEADER X-Custom-Tag nonexistent", TIMEOUT)
        .await
        .unwrap();
    assert!(
        result.ids.is_empty(),
        "HEADER with non-matching value should match nothing"
    );

    // SEARCH HEADER X-Nonexistent-Header anything — should match nothing.
    let result = conn
        .uid_search("HEADER X-Nonexistent-Header anything", TIMEOUT)
        .await
        .unwrap();
    assert!(
        result.ids.is_empty(),
        "HEADER with non-existent header field should match nothing"
    );

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

// =========================================================================
// SearchCriteria builder coverage (RFC 3501 Section 6.4.4)
// =========================================================================

/// `SearchCriteria::{bcc, cc, to}` must search the corresponding RFC 5322
/// header fields.
///
/// RFC 3501 Section 6.4.4 defines `BCC`, `CC`, and `TO` as substring matches
/// against those specific header fields.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_criteria_address_fields_match_their_headers() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-criteria-address");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let msg1 = search_test_message(SearchTestMessage {
        from: "author.one@example.com",
        to: "primary.one@example.com",
        cc: None,
        bcc: Some("hidden.one@example.com"),
        subject: "Address Field Search One",
        date: "Mon, 10 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-address-1")),
        body: "Message with Bcc only.\r\n",
    });
    let msg2 = search_test_message(SearchTestMessage {
        from: "author.two@example.com",
        to: "primary.two@example.com",
        cc: Some("carbon.copy@example.com"),
        bcc: None,
        subject: "Address Field Search Two",
        date: "Tue, 11 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-address-2")),
        body: "Message with Cc only.\r\n",
    });
    let msg3 = search_test_message(SearchTestMessage {
        from: "author.three@example.com",
        to: "exclusive.recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Address Field Search Three",
        date: "Wed, 12 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-address-3")),
        body: "Message with a unique To field.\r\n",
    });

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

    let all = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(all.len(), 3, "expected three appended messages");

    let bcc = conn
        .uid_search(
            SearchCriteria::new().bcc("hidden.one@example.com").unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        bcc.ids,
        vec![all[0]],
        "BCC must match only the message whose Bcc header contains the value"
    );

    let cc = conn
        .uid_search(
            SearchCriteria::new().cc("carbon.copy@example.com").unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        cc.ids,
        vec![all[1]],
        "CC must match only the message whose Cc header contains the value"
    );

    let to = conn
        .uid_search(
            SearchCriteria::new()
                .to("exclusive.recipient@example.com")
                .unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        to.ids,
        vec![all[2]],
        "TO must match only the message whose To header contains the value"
    );

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

/// `SearchCriteria::{larger, smaller}` must use the stored RFC 2822 message
/// size with strict inequality.
///
/// RFC 3501 Section 6.4.4 defines `LARGER n` as strictly larger than `n` and
/// `SMALLER n` as strictly smaller than `n`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_criteria_larger_and_smaller_use_rfc822_size() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-criteria-size");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let small_msg = search_test_message(SearchTestMessage {
        from: "size.small@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Small SearchCriteria Size",
        date: "Mon, 10 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-size-small")),
        body: "small body\r\n",
    });
    let large_body = "L".repeat(4096);
    let large_msg = search_test_message(SearchTestMessage {
        from: "size.large@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Large SearchCriteria Size",
        date: "Tue, 11 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-size-large")),
        body: &large_body,
    });
    let small_size = u64::try_from(small_msg.len()).unwrap();
    let large_size = u64::try_from(large_msg.len()).unwrap();

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

    let all = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(all.len(), 2, "expected two appended messages");

    let larger = conn
        .uid_search(SearchCriteria::new().larger(small_size), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        larger.ids,
        vec![all[1]],
        "LARGER must exclude the message whose size is exactly the threshold"
    );

    let smaller = conn
        .uid_search(SearchCriteria::new().smaller(large_size), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        smaller.ids,
        vec![all[0]],
        "SMALLER must exclude the message whose size is exactly the threshold"
    );

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

/// `SearchCriteria::sent_on` must match the RFC 5322 `Date:` header, not the
/// APPEND internal date.
///
/// RFC 3501 Section 6.4.4 defines `SENTON` in terms of the message's `Date:`
/// header field.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_criteria_sent_on_uses_message_date_header() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-criteria-senton");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let first = search_test_message(SearchTestMessage {
        from: "date.one@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "SentOn Match One",
        date: "Mon, 10 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-senton-1")),
        body: "Date-header match one.\r\n",
    });
    let second = search_test_message(SearchTestMessage {
        from: "date.two@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "SentOn Match Two",
        date: "Tue, 11 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-senton-2")),
        body: "Date-header match two.\r\n",
    });

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

    let all = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(all.len(), 2, "expected two appended messages");

    let sent_on = conn
        .uid_search(
            SearchCriteria::new().sent_on("10-Mar-2025").unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        sent_on.ids,
        vec![all[0]],
        "SENTON must match only the message whose Date header falls on the requested day"
    );

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

/// `SearchCriteria::{sent_before, sent_since}` must apply strict and inclusive
/// bounds to the RFC 5322 `Date:` header.
///
/// RFC 3501 Section 6.4.4 defines `SENTBEFORE` as strictly earlier than the
/// date and `SENTSINCE` as on or after the date.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_criteria_sent_date_boundaries_are_spec_compliant() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-criteria-sent-bounds");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    for (index, date) in [
        "Sun, 09 Mar 2025 10:00:00 +0000",
        "Mon, 10 Mar 2025 10:00:00 +0000",
        "Tue, 11 Mar 2025 10:00:00 +0000",
    ]
    .into_iter()
    .enumerate()
    {
        let message = search_test_message(SearchTestMessage {
            from: &format!("sent-boundary-{index}@example.com"),
            to: "recipient@example.com",
            cc: None,
            bcc: None,
            subject: &format!("Sent Boundary {index}"),
            date,
            message_id: &format!("<{}@daaki>", unique_name("search-criteria-sent-boundary")),
            body: "Boundary test body.\r\n",
        });
        conn.append(&mbox, &[], None, &message, TIMEOUT)
            .await
            .unwrap();
    }

    let all = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(all.len(), 3, "expected three appended messages");

    let sent_before = conn
        .uid_search(
            SearchCriteria::new().sent_before("10-Mar-2025").unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        sent_before.ids,
        vec![all[0]],
        "SENTBEFORE must exclude the boundary day itself"
    );

    let sent_since = conn
        .uid_search(
            SearchCriteria::new().sent_since("10-Mar-2025").unwrap(),
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        sent_since.ids,
        vec![all[1], all[2]],
        "SENTSINCE must include the boundary day and later dates"
    );

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

/// `SearchCriteria::not` must negate a compound search-key as a single unit.
///
/// RFC 3501 Section 6.4.4 defines `NOT` over a single `search-key`; compound
/// operands therefore need parentheses when serialized.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_criteria_not_negates_compound_operands() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-criteria-not");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let matching = search_test_message(SearchTestMessage {
        from: "negated.match@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Negated Compound Subject",
        date: "Mon, 10 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-not-match")),
        body: "This message matches the negated compound key.\r\n",
    });
    let same_sender = search_test_message(SearchTestMessage {
        from: "negated.match@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Different Subject",
        date: "Tue, 11 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-not-sender")),
        body: "Same sender, different subject.\r\n",
    });
    let same_subject = search_test_message(SearchTestMessage {
        from: "other.sender@example.com",
        to: "recipient@example.com",
        cc: None,
        bcc: None,
        subject: "Negated Compound Subject",
        date: "Wed, 12 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-not-subject")),
        body: "Different sender, same subject.\r\n",
    });

    conn.append(&mbox, &[], None, &matching, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, &same_sender, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, &same_subject, TIMEOUT)
        .await
        .unwrap();

    let all = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(all.len(), 3, "expected three appended messages");

    let compound = SearchCriteria::new()
        .from("negated.match@example.com")
        .unwrap()
        .subject("Negated Compound Subject")
        .unwrap();
    let negated = conn
        .uid_search(SearchCriteria::new().not(&compound), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        negated.ids,
        vec![all[1], all[2]],
        "NOT must exclude only the message matching the full compound operand"
    );

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

/// `SearchCriteria::or` must treat each compound branch as one operand.
///
/// RFC 3501 Section 6.4.4 defines `OR` as `OR search-key search-key`, so each
/// compound branch has to be parenthesized when serialized.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_criteria_or_matches_either_compound_branch() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-search-criteria-or");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let left_match = search_test_message(SearchTestMessage {
        from: "left.branch@example.com",
        to: "someone@example.com",
        cc: None,
        bcc: None,
        subject: "Compound Left Branch",
        date: "Mon, 10 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-or-left")),
        body: "Matches the left OR branch.\r\n",
    });
    let right_match = search_test_message(SearchTestMessage {
        from: "other.sender@example.com",
        to: "right.branch@example.com",
        cc: None,
        bcc: None,
        subject: "Compound Right Branch",
        date: "Tue, 11 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-or-right")),
        body: "Matches the right OR branch.\r\n",
    });
    let non_match = search_test_message(SearchTestMessage {
        from: "left.branch@example.com",
        to: "right.branch@example.com",
        cc: None,
        bcc: None,
        subject: "No Compound Match",
        date: "Wed, 12 Mar 2025 10:00:00 +0000",
        message_id: &format!("<{}@daaki>", unique_name("search-criteria-or-none")),
        body: "Shares individual fields but not either full branch.\r\n",
    });

    conn.append(&mbox, &[], None, &left_match, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, &right_match, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mbox, &[], None, &non_match, TIMEOUT)
        .await
        .unwrap();

    let all = conn.uid_search("ALL", TIMEOUT).await.unwrap().ids;
    assert_eq!(all.len(), 3, "expected three appended messages");

    let left = SearchCriteria::new()
        .from("left.branch@example.com")
        .unwrap()
        .subject("Compound Left Branch")
        .unwrap();
    let right = SearchCriteria::new()
        .to("right.branch@example.com")
        .unwrap()
        .subject("Compound Right Branch")
        .unwrap();
    let either = conn
        .uid_search(SearchCriteria::new().or(&left, &right), TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        either.ids,
        vec![all[0], all[1]],
        "OR must match messages satisfying either full compound branch"
    );

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

// =========================================================================
// SELECT with CONDSTORE option (RFC 7162 Section 3.1.2.1)
// =========================================================================

/// `select_with(SelectOptions::condstore())` sends `SELECT <mailbox> (CONDSTORE)`
/// and MUST return HIGHESTMODSEQ in the OK response.
///
/// RFC 7162 Section 3.1.2.1: A server that supports CONDSTORE MUST include
/// the HIGHESTMODSEQ response code in the tagged OK to SELECT/EXAMINE when
/// CONDSTORE is requested via the select parameter.
///
/// This differs from `dovecot_condstore_highestmodseq_in_select` which uses
/// `enable(CONDSTORE)` + plain `select()`. Here we test the per-command
/// CONDSTORE activation via `SelectOptions::condstore()`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_select_with_condstore() {
    use daaki_imap::types::SelectOptions;

    let conn = connect_dovecot().await;

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

    // Append a message so the mailbox is non-empty.
    conn.append(mbox, &[Flag::Seen], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

    // SELECT with (CONDSTORE).
    let selected = conn
        .select_with(mbox, &SelectOptions::condstore(), TIMEOUT)
        .await
        .unwrap();

    // Must NOT be read-only (SELECT, not EXAMINE).
    assert!(
        !selected.read_only,
        "SELECT should open mailbox in read-write mode"
    );

    // Must return HIGHESTMODSEQ per RFC 7162 Section 3.1.2.1.
    assert!(
        selected.highest_mod_seq.is_some(),
        "SELECT with CONDSTORE must return HIGHESTMODSEQ"
    );

    // UIDVALIDITY must be present and non-zero.
    let uid_validity = selected
        .uid_validity
        .expect("UIDVALIDITY should be present");
    assert!(uid_validity > 0, "UIDVALIDITY should be > 0");

    // Must report at least one message.
    assert_eq!(selected.exists, 1, "expected 1 message in mailbox");

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

// =========================================================================
// BODY.PEEK vs BODY and \Seen flag (RFC 3501 Section 6.4.5)
// =========================================================================

/// BODY.PEEK[] MUST NOT set the \Seen flag; BODY[] MUST set it.
///
/// RFC 3501 Section 6.4.5: "BODY.PEEK[<section>]" is an alternate form
/// of BODY[<section>] that does not implicitly set the \Seen flag.
/// Fetching with BODY[] (without PEEK) implicitly sets \Seen.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_body_peek_preserves_unseen() {
    let conn = connect_dovecot().await;

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

    // Append a message WITHOUT \Seen.
    conn.append(mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();

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

    // Fetch UID first.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one message");
    let uid = fetches[0].uid.unwrap();

    // BODY.PEEK[] — should NOT set \Seen.
    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!(!fetches.is_empty());
    let data = fetches[0].body_sections[0]
        .data
        .as_ref()
        .expect("expected body data from PEEK");
    assert!(!data.is_empty(), "BODY.PEEK[] should return non-empty data");

    // Check flags — \Seen should NOT be present.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_deref().unwrap_or_default();
    assert!(
        !flags.contains(&Flag::Seen),
        "BODY.PEEK[] must NOT set \\Seen flag (RFC 3501 Section 6.4.5), got: {flags:?}"
    );

    // Now fetch with BODY[] (no peek) — should set \Seen.
    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!(!fetches.is_empty());

    // Check flags — \Seen should now be present.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_deref().unwrap_or_default();
    assert!(
        flags.contains(&Flag::Seen),
        "BODY[] must set \\Seen flag (RFC 3501 Section 6.4.5), got: {flags:?}"
    );

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

// =========================================================================
// Pipeline LISTRIGHTS (RFC 4314 Section 3.4)
// =========================================================================

/// LISTRIGHTS returns the required and optional rights for an identifier
/// on a mailbox.
///
/// RFC 4314 Section 3.4: The LISTRIGHTS command takes a mailbox name and
/// an identifier as arguments. It returns a LISTRIGHTS response with the
/// set of rights the identifier always has (required) and the groups of
/// optional rights.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_list_rights() {
    let conn = connect_dovecot().await;

    // Check ACL capability.
    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Acl))
    {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

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

    let mbox_name = MailboxName::new(mbox.clone()).unwrap();

    let (rights_result,) = conn
        .pipeline()
        .list_rights(mbox_name, "anyone".to_owned())
        .execute()
        .await
        .unwrap();

    let rights = rights_result.unwrap();

    // RFC 4314 Section 3.4: The server MUST return a LISTRIGHTS response.
    // The required rights string may be empty, but the response must exist.
    // The optional rights should contain at least some right groups.
    assert!(
        !rights.optional.is_empty(),
        "expected at least one group of optional rights, got: {rights:?}"
    );

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

/// LISTRIGHTS must not repeat a right across the required and optional groups.
///
/// RFC 4314 Section 3.7: "The same right MUST NOT be listed more than once in
/// the LISTRIGHTS command."
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_rights_does_not_repeat_any_right() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Acl))
    {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-list-rights-unique");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn.list_rights(&mailbox, "anyone", TIMEOUT).await.unwrap();
    let mut seen = BTreeSet::new();

    for ch in rights
        .required
        .chars()
        .chain(rights.optional.iter().flat_map(|group| group.chars()))
    {
        assert!(
            seen.insert(ch),
            "LISTRIGHTS must not repeat right {ch:?}; got {rights:?}"
        );
    }

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

/// SETACL with a leading `+` adds rights without removing existing grants.
///
/// RFC 4314 Section 3.1 defines `+<mod-rights>` as adding the specified rights
/// to the existing ACL entry.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_acl_plus_adds_rights_without_removing_existing_rights() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Acl))
    {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-plus");
    let identifier = unique_name("acl-plus-ident");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn
        .list_rights(&mailbox, &identifier, TIMEOUT)
        .await
        .unwrap();
    let Some((baseline_group, added_group)) = pick_two_non_virtual_acl_groups(&rights) else {
        eprintln!("skipping: Dovecot did not advertise two disjoint ACL groups");
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    conn.set_acl(&mailbox, &identifier, &baseline_group, TIMEOUT)
        .await
        .unwrap();
    let baseline_entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let baseline_rights = acl_rights_for(&baseline_entries, &identifier)
        .expect("SETACL must create an ACL entry for the identifier")
        .to_owned();
    let baseline_set = rights_char_set(&baseline_rights);

    assert!(
        rights_char_set(&baseline_group).is_subset(&baseline_set),
        "replaced ACL entry must include the requested baseline rights; requested={baseline_group:?} actual={baseline_rights:?}"
    );

    conn.set_acl(&mailbox, &identifier, &format!("+{added_group}"), TIMEOUT)
        .await
        .unwrap();
    let updated_entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let updated_rights = acl_rights_for(&updated_entries, &identifier)
        .expect("SETACL +rights must keep the ACL entry present")
        .to_owned();
    let updated_set = rights_char_set(&updated_rights);

    assert!(
        baseline_set.is_subset(&updated_set),
        "SETACL +rights must not remove pre-existing rights; before={baseline_rights:?} after={updated_rights:?}"
    );
    assert!(
        rights_char_set(&added_group).is_subset(&updated_set),
        "SETACL +rights must add the requested group; requested={added_group:?} after={updated_rights:?}"
    );

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

/// SETACL with a leading `-` removes only the specified rights.
///
/// RFC 4314 Section 3.1 defines `-<mod-rights>` as removing the specified
/// rights from the existing ACL entry.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_acl_minus_removes_only_requested_rights() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Acl))
    {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-minus");
    let identifier = unique_name("acl-minus-ident");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn
        .list_rights(&mailbox, &identifier, TIMEOUT)
        .await
        .unwrap();
    let Some((kept_group, removed_group)) = pick_two_non_virtual_acl_groups(&rights) else {
        eprintln!("skipping: Dovecot did not advertise two disjoint ACL groups");
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    conn.set_acl(
        &mailbox,
        &identifier,
        &format!("{kept_group}{removed_group}"),
        TIMEOUT,
    )
    .await
    .unwrap();
    let before_entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let before_rights = acl_rights_for(&before_entries, &identifier)
        .expect("SETACL must create an ACL entry for the identifier")
        .to_owned();
    let before_set = rights_char_set(&before_rights);

    assert!(
        rights_char_set(&kept_group).is_subset(&before_set),
        "initial ACL entry must include kept rights; requested={kept_group:?} actual={before_rights:?}"
    );
    assert!(
        rights_char_set(&removed_group).is_subset(&before_set),
        "initial ACL entry must include removed rights; requested={removed_group:?} actual={before_rights:?}"
    );

    conn.set_acl(&mailbox, &identifier, &format!("-{removed_group}"), TIMEOUT)
        .await
        .unwrap();
    let after_entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let after_rights = acl_rights_for(&after_entries, &identifier)
        .expect("SETACL -rights must keep the ACL entry present when rights remain")
        .to_owned();
    let after_set = rights_char_set(&after_rights);

    assert!(
        rights_char_set(&kept_group).is_subset(&after_set),
        "SETACL -rights must preserve unrelated rights; kept_group={kept_group:?} after={after_rights:?}"
    );
    assert!(
        rights_char_set(&removed_group).is_disjoint(&after_set),
        "SETACL -rights must remove the requested rights; removed_group={removed_group:?} after={after_rights:?}"
    );

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

/// DELETEACL removes only the named identifier and leaves other ACL entries
/// untouched.
///
/// RFC 4314 Section 3.2 removes one identifier from the ACL; it does not
/// redefine or delete unrelated entries.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_acl_preserves_other_identifiers() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Acl))
    {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-delete-acl-one");
    let identifier_a = unique_name("acl-ident-a");
    let identifier_b = unique_name("acl-ident-b");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn
        .list_rights(&mailbox, &identifier_a, TIMEOUT)
        .await
        .unwrap();
    let Some(group) = pick_one_non_virtual_acl_group(&rights) else {
        eprintln!("skipping: Dovecot did not advertise any usable ACL group");
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    conn.set_acl(&mailbox, &identifier_a, &group, TIMEOUT)
        .await
        .unwrap();
    conn.set_acl(&mailbox, &identifier_b, &group, TIMEOUT)
        .await
        .unwrap();

    let before_entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let before_b = acl_rights_for(&before_entries, &identifier_b)
        .expect("second ACL entry must be present before DELETEACL")
        .to_owned();

    conn.delete_acl(&mailbox, &identifier_a, TIMEOUT)
        .await
        .unwrap();

    let after_entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let after_b = acl_rights_for(&after_entries, &identifier_b)
        .expect("second ACL entry must remain present after DELETEACL");
    assert!(
        acl_rights_for(&after_entries, &identifier_a).is_none(),
        "DELETEACL must remove only the named identifier"
    );
    assert_eq!(
        rights_char_set(after_b),
        rights_char_set(&before_b),
        "DELETEACL must not alter other ACL entries"
    );

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

/// GETACL on a selected mailbox must surface the ACL entry previously granted
/// with SETACL and leave the mailbox selected.
///
/// RFC 4314 Section 3.3 defines GETACL as returning the identifier/rights
/// pairs for a mailbox. RFC 3501 Section 6.4 says commands valid in selected
/// state must not implicitly deselect the current mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_acl_selected_mailbox_returns_granted_identifier_and_preserves_selected_state()
{
    let user = unique_name("acl-get-selected-user");
    let conn = connect_dovecot_as(&user).await;

    if !has_capability(&conn, &Capability::Acl) {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-get-selected");
    let identifier = unique_name("acl-get-selected-ident");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn
        .list_rights(&mailbox, &identifier, TIMEOUT)
        .await
        .unwrap();
    let Some(group) = pick_one_non_virtual_acl_group(&rights) else {
        eprintln!("skipping: Dovecot did not advertise any usable ACL group");
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    conn.set_acl(&mailbox, &identifier, &group, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let actual_rights = acl_rights_for(&entries, &identifier)
        .expect("GETACL must return the identifier created by SETACL")
        .to_owned();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "GETACL must not leave selected state"
    );
    assert!(
        rights_char_set(&group).is_subset(&rights_char_set(&actual_rights)),
        "GETACL must report at least the rights granted by SETACL; requested={group:?} actual={actual_rights:?}"
    );
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "selected mailbox must remain usable after GETACL"
    );

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

/// Bare SETACL rights replace the existing rights string rather than merging
/// with it.
///
/// RFC 4314 Section 3.1 defines a bare rights string as replacing the
/// identifier's ACL entry, unlike the `+` and `-` forms which modify it.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_acl_bare_rights_replace_previous_grant() {
    let user = unique_name("acl-replace-user");
    let conn = connect_dovecot_as(&user).await;

    if !has_capability(&conn, &Capability::Acl) {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-replace");
    let identifier = unique_name("acl-replace-ident");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn
        .list_rights(&mailbox, &identifier, TIMEOUT)
        .await
        .unwrap();
    let Some((first_group, second_group)) = pick_two_non_virtual_acl_groups(&rights) else {
        eprintln!("skipping: Dovecot did not advertise two disjoint ACL groups");
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

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

    conn.set_acl(&mailbox, &identifier, &first_group, TIMEOUT)
        .await
        .unwrap();
    conn.set_acl(&mailbox, &identifier, &second_group, TIMEOUT)
        .await
        .unwrap();

    let entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    let actual_rights = acl_rights_for(&entries, &identifier)
        .expect("SETACL must keep an ACL entry for the identifier")
        .to_owned();
    let actual_set = rights_char_set(&actual_rights);

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "SETACL must not leave selected state"
    );
    assert!(
        rights_char_set(&second_group).is_subset(&actual_set),
        "bare SETACL must apply the replacement rights; replacement={second_group:?} actual={actual_rights:?}"
    );
    assert!(
        rights_char_set(&first_group).is_disjoint(&actual_set),
        "bare SETACL must replace rather than merge rights; previous={first_group:?} actual={actual_rights:?}"
    );
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "selected mailbox must remain usable after SETACL"
    );

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

/// DELETEACL on a selected mailbox must remove the named identifier without
/// deselecting the mailbox.
///
/// RFC 4314 Section 3.2 removes a single identifier from the mailbox ACL.
/// RFC 3501 Section 6.4 allows ACL commands in selected state without changing
/// the selected mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_delete_acl_selected_mailbox_removes_identifier_and_preserves_selected_state() {
    let user = unique_name("acl-delete-selected-user");
    let conn = connect_dovecot_as(&user).await;

    if !has_capability(&conn, &Capability::Acl) {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-delete-selected");
    let identifier = unique_name("acl-delete-selected-ident");
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn
        .list_rights(&mailbox, &identifier, TIMEOUT)
        .await
        .unwrap();
    let Some(group) = pick_one_non_virtual_acl_group(&rights) else {
        eprintln!("skipping: Dovecot did not advertise any usable ACL group");
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    conn.set_acl(&mailbox, &identifier, &group, TIMEOUT)
        .await
        .unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.delete_acl(&mailbox, &identifier, TIMEOUT)
        .await
        .unwrap();

    let entries = conn.get_acl(&mailbox, TIMEOUT).await.unwrap();
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "DELETEACL must not leave selected state"
    );
    assert!(
        acl_rights_for(&entries, &identifier).is_none(),
        "DELETEACL must remove the named ACL entry"
    );
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "selected mailbox must remain usable after DELETEACL"
    );

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

/// LISTRIGHTS on a selected mailbox must not deselect the mailbox.
///
/// RFC 4314 Section 3.4 defines LISTRIGHTS as a mailbox/identifier query and
/// RFC 3501 Section 6.4 allows such commands to execute while a mailbox
/// remains selected.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_rights_selected_mailbox_preserves_selected_state() {
    let user = unique_name("acl-list-rights-selected-user");
    let conn = connect_dovecot_as(&user).await;

    if !has_capability(&conn, &Capability::Acl) {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-list-rights-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    conn.list_rights(&mailbox, "anyone", TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "LISTRIGHTS must not leave selected state"
    );
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "selected mailbox must remain usable after LISTRIGHTS"
    );

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

/// MYRIGHTS on a selected mailbox must report the current user's rights
/// without deselecting the mailbox.
///
/// RFC 4314 Section 3.5 returns the rights available to the logged-in user on
/// the mailbox. RFC 3501 Section 6.4 requires selected-state commands to leave
/// the selected mailbox intact.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_my_rights_selected_mailbox_preserves_selected_state() {
    let user = unique_name("acl-my-rights-selected-user");
    let conn = connect_dovecot_as(&user).await;

    if !has_capability(&conn, &Capability::Acl) {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-acl-my-rights-selected");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let rights = conn.my_rights(&mailbox, TIMEOUT).await.unwrap();

    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "MYRIGHTS must not leave selected state"
    );
    assert!(
        !rights.is_empty(),
        "MYRIGHTS must report at least one right for the mailbox owner"
    );
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "selected mailbox must remain usable after MYRIGHTS"
    );

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

// =========================================================================
// Selected-state extension invariants
// =========================================================================

/// `NAMESPACE` on a selected mailbox must return namespace data without
/// deselecting the mailbox.
///
/// RFC 2342 Section 4 allows `NAMESPACE` in the authenticated and selected
/// states, and RFC 3501 Section 6.4 requires selected-state commands to leave
/// the selected mailbox usable for later commands.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_namespace_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Namespace) {
        eprintln!("skipping: Dovecot does not advertise NAMESPACE");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-namespace-selected").await;

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

    assert_selected_mailbox_still_usable(&conn, "NAMESPACE").await;

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

/// `ID` is valid in any state and must not deselect the current mailbox.
///
/// RFC 2971 Section 3.1 permits `ID` in any state, and RFC 3501 Section 6.4
/// requires commands that are valid in selected state to leave the mailbox
/// selected and usable.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_id_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Id) {
        eprintln!("skipping: Dovecot does not advertise ID");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-id-selected").await;

    conn.id(
        &[("name", Some("daaki-selected-id")), ("support-url", None)],
        TIMEOUT,
    )
    .await
    .unwrap();

    assert_selected_mailbox_still_usable(&conn, "ID").await;

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

/// `LIST (RETURN (CHILDREN))` on a selected mailbox must not deselect the
/// current mailbox.
///
/// RFC 5258 Section 3 defines extended `LIST` return options such as
/// `CHILDREN`, and RFC 3501 Section 6.4 requires selected-state commands to
/// leave the selected mailbox intact.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_extended_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ListExtended) {
        eprintln!("skipping: Dovecot does not advertise LIST-EXTENDED");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-list-extended-selected").await;
    let delimiter = hierarchy_delimiter(&conn).await;
    let child_mailbox = format!("{mailbox}{delimiter}child");
    conn.create(&child_mailbox, TIMEOUT).await.unwrap();

    let listing = conn
        .list_extended("", &[&mailbox], &[], &["CHILDREN"], TIMEOUT)
        .await
        .unwrap();
    let mailbox_info = listing
        .iter()
        .find(|info| info.name.as_str().eq_ignore_ascii_case(&mailbox))
        .expect("LIST-EXTENDED must include the queried selected mailbox");
    assert!(
        mailbox_info
            .attributes
            .contains(&MailboxAttribute::HasChildren),
        "LIST-EXTENDED RETURN (CHILDREN) must mark the parent mailbox with \\HasChildren"
    );

    assert_selected_mailbox_still_usable(&conn, "LIST-EXTENDED").await;

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

/// `GETMETADATA` on a selected mailbox must return the requested entry without
/// deselecting the mailbox.
///
/// RFC 5464 Section 4.2 defines `GETMETADATA`, and RFC 3501 Section 6.4
/// requires commands valid in selected state to leave the mailbox selected.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_metadata_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Metadata) {
        eprintln!("skipping: Dovecot does not advertise METADATA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-get-metadata-selected").await;
    let value = b"selected getmetadata";
    conn.set_metadata(
        &mailbox,
        &[("/private/comment", Some(value.as_slice()))],
        TIMEOUT,
    )
    .await
    .unwrap();

    let result = conn
        .get_metadata(&mailbox, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap();
    assert!(
        !result.notify_ambiguity,
        "GETMETADATA without active metadata NOTIFY delivery must be unambiguous"
    );
    assert_eq!(
        metadata_entry_named(&result.entries, "/private/comment")
            .and_then(|entry| entry.value.as_deref()),
        Some(value.as_slice()),
        "GETMETADATA must return the stored /private/comment value"
    );

    assert_selected_mailbox_still_usable(&conn, "GETMETADATA").await;

    conn.set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `SETMETADATA` on a selected mailbox must update the targeted entry without
/// deselecting the mailbox.
///
/// RFC 5464 Section 4.3 defines `SETMETADATA`, and RFC 3501 Section 6.4
/// requires commands that are valid in selected state to leave the selected
/// mailbox usable for subsequent commands.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_metadata_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Metadata) {
        eprintln!("skipping: Dovecot does not advertise METADATA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-set-metadata-selected").await;
    let value = b"selected setmetadata";
    conn.set_metadata(
        &mailbox,
        &[("/private/comment", Some(value.as_slice()))],
        TIMEOUT,
    )
    .await
    .unwrap();

    let result = conn
        .get_metadata(&mailbox, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        metadata_entry_named(&result.entries, "/private/comment")
            .and_then(|entry| entry.value.as_deref()),
        Some(value.as_slice()),
        "SETMETADATA must persist the new /private/comment value"
    );

    assert_selected_mailbox_still_usable(&conn, "SETMETADATA").await;

    conn.set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();
    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();
}

/// `GETQUOTAROOT` on a selected mailbox must not deselect the mailbox,
/// regardless of whether quota data is configured.
///
/// RFC 2087 Section 4.3 defines `GETQUOTAROOT`, and RFC 3501 Section 6.4
/// requires selected-state commands to leave the current mailbox selected even
/// when the server reports a command failure.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota_root_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Quota) {
        eprintln!("skipping: Dovecot does not advertise QUOTA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-get-quota-root-selected").await;

    match conn.get_quota_root(&mailbox, TIMEOUT).await {
        Ok(_) | Err(daaki_imap::Error::No { .. } | daaki_imap::Error::Bad { .. }) => {}
        Err(err) => panic!("unexpected GETQUOTAROOT failure: {err:?}"),
    }

    assert_selected_mailbox_still_usable(&conn, "GETQUOTAROOT").await;

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

/// `GETQUOTA` on a selected mailbox's quota root must not deselect the
/// mailbox.
///
/// RFC 2087 Section 4.2 and RFC 9208 Section 4.1.1 define `GETQUOTA` for a
/// quota root, and RFC 3501 Section 6.4 requires selected-state commands to
/// leave the selected mailbox usable for subsequent commands.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_quota_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Quota) {
        eprintln!("skipping: Dovecot does not advertise QUOTA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-get-quota-selected").await;
    let quota_root = conn.get_quota_root(&mailbox, TIMEOUT).await.unwrap();
    let Some(root) = quota_root.roots.first().cloned() else {
        eprintln!("skipping: Dovecot reported no quota roots for selected mailbox");
        conn.close(TIMEOUT).await.unwrap();
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    let expected = quota_root
        .resources
        .iter()
        .find(|(name, _)| name == &root)
        .expect("GETQUOTAROOT must include resources for each listed root");

    let direct = conn.get_quota(&root, TIMEOUT).await.unwrap();

    assert_eq!(
        quota_resource_map(&direct),
        quota_resource_map(&expected.1),
        "GETQUOTA must agree with GETQUOTAROOT for root {root:?}"
    );
    assert_selected_mailbox_still_usable(&conn, "GETQUOTA").await;

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

/// `SETQUOTA` on a selected mailbox's quota root must not deselect the
/// mailbox, even when the server rejects the mutation.
///
/// RFC 2087 Section 4.1 and RFC 9208 Section 4.1.3 define `SETQUOTA` over a
/// quota root, and RFC 3501 Section 6.4 requires commands valid in selected
/// state to leave the selected mailbox intact regardless of success or
/// failure.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_quota_selected_preserves_selected_mailbox() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::QuotaSet) {
        eprintln!("skipping: Dovecot does not advertise QUOTASET");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = prepare_selected_mailbox(&conn, "test-set-quota-selected").await;
    let quota_root = conn.get_quota_root(&mailbox, TIMEOUT).await.unwrap();
    let Some(root) = quota_root.roots.first().cloned() else {
        eprintln!("skipping: Dovecot reported no quota roots for selected mailbox");
        conn.close(TIMEOUT).await.unwrap();
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    };

    let existing = quota_root
        .resources
        .iter()
        .find(|(name, _)| name == &root)
        .map(|(_, resources)| resources.clone())
        .unwrap_or_default();
    if existing.is_empty() {
        eprintln!("skipping: Dovecot returned no quota resources for root {root:?}");
        conn.close(TIMEOUT).await.unwrap();
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    }

    let request: Vec<_> = existing
        .iter()
        .map(|resource| (resource.name.as_str(), resource.limit))
        .collect();

    match conn.set_quota(&root, &request, TIMEOUT).await {
        Ok(updated) => {
            let follow_up = conn.get_quota(&root, TIMEOUT).await.unwrap();
            assert_eq!(
                quota_resource_map(&updated),
                quota_resource_map(&follow_up),
                "SETQUOTA and follow-up GETQUOTA must agree for root {root:?}"
            );
        }
        Err(daaki_imap::Error::No { .. } | daaki_imap::Error::Bad { .. }) => {}
        Err(err) => panic!("unexpected SETQUOTA failure: {err:?}"),
    }

    assert_selected_mailbox_still_usable(&conn, "SETQUOTA").await;

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

// =========================================================================
// SEARCH KEYWORD after STORE keyword (RFC 3501 Section 6.4.4)
// =========================================================================

/// Store a custom keyword flag on a message, then search by KEYWORD and
/// UNKEYWORD criteria.
///
/// RFC 3501 Section 6.4.4:
/// - KEYWORD <flag>: Messages with the specified keyword flag set.
/// - UNKEYWORD <flag>: Messages without the specified keyword flag.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_search_keyword_after_store() {
    let conn = connect_dovecot().await;

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

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

    let msg2 = b"\
From: other@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Second message\r\n\
Date: Mon, 16 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <kw-test-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";
    conn.append(mbox, &[], None, msg2, TIMEOUT).await.unwrap();

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

    // Get UIDs.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 2, "expected 2 messages");
    let uid1 = fetches[0].uid.unwrap();

    // Store custom keyword on first message only.
    conn.uid_store(
        &SequenceSet::new(uid1.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Custom("$TestLabel".into())],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    // SEARCH KEYWORD $TestLabel — should find exactly 1 message.
    let result = conn
        .uid_search("KEYWORD $TestLabel", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        result.ids.len(),
        1,
        "KEYWORD $TestLabel should match 1 message, got: {:?}",
        result.ids
    );
    assert!(
        result.ids.contains(&uid1),
        "KEYWORD search should find the message we tagged"
    );

    // SEARCH UNKEYWORD $TestLabel — should find the other message.
    let result = conn
        .uid_search("UNKEYWORD $TestLabel", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        result.ids.len(),
        1,
        "UNKEYWORD $TestLabel should match 1 message, got: {:?}",
        result.ids
    );
    assert!(
        !result.ids.contains(&uid1),
        "UNKEYWORD search should NOT include the tagged message"
    );

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

// =========================================================================
// Pipeline SETQUOTA (RFC 2087 / RFC 9208)
// =========================================================================

/// SETQUOTA via pipeline sets a quota resource limit and returns the
/// updated quota.
///
/// RFC 2087 Section 4.1 / RFC 9208 Section 4.1.3: The SETQUOTA command
/// takes a quota root and a list of resource limit pairs. The server
/// responds with the resulting QUOTA.
///
/// Requires the QUOTASET capability.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_set_quota() {
    let conn = connect_dovecot().await;

    // Check QUOTASET capability — SETQUOTA requires it.
    let has_quotaset = conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::QuotaSet));
    if !has_quotaset {
        eprintln!("skipping: Dovecot does not advertise QUOTASET");
        conn.logout().await.unwrap();
        return;
    }

    // SETQUOTA on the user's root (typically "").
    let (quota_result,) = conn
        .pipeline()
        .set_quota(String::new(), vec![("STORAGE".to_owned(), 1_048_576)])
        .execute()
        .await
        .unwrap();

    match quota_result {
        Ok(resources) => {
            // Should return at least one resource.
            assert!(
                !resources.is_empty(),
                "SETQUOTA should return quota resources"
            );
            let storage = resources.iter().find(|r| r.name == "STORAGE");
            assert!(
                storage.is_some(),
                "expected STORAGE resource in SETQUOTA response"
            );
        }
        // Some Dovecot configurations don't allow quota changes.
        Err(daaki_imap::Error::No { .. }) => {
            eprintln!("SETQUOTA rejected (admin-only or quota plugin not configured)");
        }
        Err(e) => panic!("unexpected SETQUOTA error: {e:?}"),
    }

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

// =========================================================================
// FETCH BINARY section (RFC 3516)
// =========================================================================

/// Decoded bytes of the base64 MIME part used by the BINARY integration tests.
///
/// RFC 3516 Section 4.2 requires `BINARY[section]` to return the MIME part
/// after decoding its Content-Transfer-Encoding.
const BINARY_TEST_DECODED_PART: &[u8] = b"Hello from base64.";

/// Base64 body for [`BINARY_TEST_DECODED_PART`].
///
/// RFC 2045 Section 6.8 defines the Base64 content-transfer-encoding that
/// RFC 3516 Section 4.2 requires the server to decode before returning
/// `BINARY[section]`.
const BINARY_TEST_BASE64_PART: &str = "SGVsbG8gZnJvbSBiYXNlNjQu";

/// Return whether Dovecot advertises the BINARY capability for this session.
///
/// RFC 3501 Section 6.1.1 and RFC 3516 Section 5 require clients to discover
/// extension support via CAPABILITY before issuing extension commands/data
/// items.
fn require_dovecot_binary(conn: &ImapConnection) -> bool {
    let has_binary = has_capability(conn, &Capability::Binary);
    if !has_binary {
        eprintln!("skipping: Dovecot does not advertise BINARY");
    }
    has_binary
}

/// Build the multipart message fixture used by the Dovecot BINARY tests.
///
/// RFC 2045 Section 6.8 defines the base64 transfer encoding, and RFC 3516
/// Section 4.2 requires `BINARY[section]` to return the decoded MIME part.
fn binary_fixture_message() -> Vec<u8> {
    format!(
        "\
From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Binary test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <binary-test@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"binboundary\"\r\n\
\r\n\
--binboundary\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: 7bit\r\n\
\r\n\
Plain text part.\r\n\
--binboundary\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
{BINARY_TEST_BASE64_PART}\r\n\
--binboundary--\r\n"
    )
    .into_bytes()
}

/// Append the shared BINARY fixture to `mailbox` and return the stored UID.
///
/// RFC 3501 Section 6.3.11 defines APPEND, and RFC 3501 Section 7.4.2 defines
/// `UID` as a FETCH data item the client can use to address the appended
/// message precisely.
async fn append_binary_fixture(conn: &ImapConnection, mailbox: &str) -> u32 {
    conn.append(mailbox, &[], None, &binary_fixture_message(), TIMEOUT)
        .await
        .unwrap();
    conn.select(mailbox, TIMEOUT).await.unwrap();
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "expected exactly one appended BINARY test message"
    );
    fetches[0]
        .uid
        .expect("appended BINARY test message must have a UID")
}

/// BINARY fetch retrieves a decoded MIME part.
///
/// RFC 3516 Section 4.2: The BINARY fetch data item retrieves the
/// content of a MIME part after decoding its Content-Transfer-Encoding.
///
/// This test appends a message with a base64-encoded part and fetches it
/// via BINARY to verify decoded content is returned.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_binary_section() {
    let conn = connect_dovecot().await;

    if !require_dovecot_binary(&conn) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-binary");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    append_binary_fixture(&conn, &mbox).await;

    // BINARY[2] — fetch the base64-encoded part decoded.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Binary {
                    peek: true,
                    section: vec![2],
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH result");

    // The BINARY response should contain the decoded content.
    let msg_resp = &fetches[0];
    assert!(
        !msg_resp.binary_sections.is_empty(),
        "expected BINARY section data in response, got: {msg_resp:?}"
    );
    let binary = &msg_resp.binary_sections[0];
    assert_eq!(
        binary.section,
        vec![2],
        "BINARY[2] must identify section 2 in the response"
    );
    let decoded = binary.data.as_ref().expect("expected decoded binary data");
    assert!(
        decoded == BINARY_TEST_DECODED_PART,
        "BINARY[2] must return the decoded MIME-part octets, got: {decoded:?}"
    );

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

/// `BINARY.SIZE[2]` must report the decoded octet count of the MIME part.
///
/// RFC 3516 Section 4.3 and Section 4.5.2 define `BINARY.SIZE[section]` as
/// the size in octets of the section after Content-Transfer-Encoding decoding.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_binary_size_reports_decoded_octet_count() {
    let conn = connect_dovecot().await;

    if !require_dovecot_binary(&conn) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-binary-size");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    let uid = append_binary_fixture(&conn, &mbox).await;

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::BinarySize { section: vec![2] }],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(fetches.len(), 1, "expected one FETCH response");
    let size = fetches[0]
        .binary_sizes
        .iter()
        .find_map(|(section, size)| (section == &vec![2]).then_some(*size))
        .expect("expected BINARY.SIZE[2] in FETCH response");
    assert_eq!(
        size,
        BINARY_TEST_DECODED_PART.len() as u64,
        "BINARY.SIZE[2] must report the decoded octet count"
    );
    assert_ne!(
        size,
        BINARY_TEST_BASE64_PART.len() as u64,
        "BINARY.SIZE[2] must not report the encoded base64 line length"
    );

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

/// `BINARY.PEEK[2]` must not set `\\Seen`.
///
/// RFC 3516 Section 4.5.1 defines `BINARY.PEEK[section]` as the form that does
/// not implicitly set the `\\Seen` flag, mirroring `BODY.PEEK`.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_binary_peek_preserves_unseen() {
    let conn = connect_dovecot().await;

    if !require_dovecot_binary(&conn) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-binary-peek");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    let uid = append_binary_fixture(&conn, &mbox).await;

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Binary {
                    peek: true,
                    section: vec![2],
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected one FETCH response");
    assert_eq!(
        fetches[0].binary_sections[0].data.as_deref(),
        Some(BINARY_TEST_DECODED_PART),
        "BINARY.PEEK[2] must return the decoded octets for section 2"
    );

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_deref().unwrap_or_default();
    assert!(
        !flags.contains(&Flag::Seen),
        "BINARY.PEEK[2] must not set \\Seen, got: {flags:?}"
    );

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

/// `BINARY[2]` without `PEEK` must set `\\Seen`.
///
/// RFC 3516 Section 4.5.1 defines `BINARY[section]` as the FETCH form that
/// implicitly sets `\\Seen` unless the `.PEEK` variant is used.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_binary_without_peek_marks_seen() {
    let conn = connect_dovecot().await;

    if !require_dovecot_binary(&conn) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-binary-seen");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    let uid = append_binary_fixture(&conn, &mbox).await;

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Binary {
                    peek: false,
                    section: vec![2],
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 1, "expected one FETCH response");
    assert_eq!(
        fetches[0].binary_sections[0].data.as_deref(),
        Some(BINARY_TEST_DECODED_PART),
        "BINARY[2] must return the decoded octets for section 2"
    );

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::Flags],
            TIMEOUT,
        )
        .await
        .unwrap();
    let flags = fetches[0].flags.as_deref().unwrap_or_default();
    assert!(
        flags.contains(&Flag::Seen),
        "BINARY[2] must set \\Seen, got: {flags:?}"
    );

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

/// Partial `BINARY.PEEK[2]<6.4>` must return that decoded octet window and
/// report the requested origin.
///
/// RFC 3516 Section 4.5.1 extends `BINARY[section]` with the same partial
/// range syntax as `BODY[section]`, applied to the decoded octet stream.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_binary_partial_returns_requested_decoded_window() {
    let conn = connect_dovecot().await;

    if !require_dovecot_binary(&conn) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-binary-partial");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    let uid = append_binary_fixture(&conn, &mbox).await;

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Binary {
                    peek: true,
                    section: vec![2],
                    partial: Some((6, 4)),
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(fetches.len(), 1, "expected one FETCH response");
    let binary = fetches[0]
        .binary_sections
        .first()
        .expect("expected one BINARY section");
    assert_eq!(
        binary.section,
        vec![2],
        "partial BINARY response must identify section 2"
    );
    assert_eq!(
        binary.origin,
        Some(6),
        "partial BINARY response must report the requested origin"
    );
    assert_eq!(
        binary.data.as_deref(),
        Some(&BINARY_TEST_DECODED_PART[6..10]),
        "BINARY.PEEK[2]<6.4> must return decoded octets 6..10"
    );

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

/// Partial `BINARY.PEEK[2]` that runs past the end of the decoded part must
/// return only the remaining decoded octets.
///
/// RFC 3516 Section 4.5.1 reuses IMAP's partial-fetch semantics for
/// `BINARY[section]<offset.count>`, so a short tail fetch returns the bytes
/// that remain in the decoded section.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_binary_partial_truncates_at_end_of_decoded_part() {
    let conn = connect_dovecot().await;

    if !require_dovecot_binary(&conn) {
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-binary-partial-tail");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    let uid = append_binary_fixture(&conn, &mbox).await;

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new(uid.to_string()).unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::Binary {
                    peek: true,
                    section: vec![2],
                    partial: Some((11, 100)),
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(fetches.len(), 1, "expected one FETCH response");
    let binary = fetches[0]
        .binary_sections
        .first()
        .expect("expected one BINARY section");
    assert_eq!(
        binary.origin,
        Some(11),
        "partial BINARY response must report the requested origin"
    );
    let tail = &BINARY_TEST_DECODED_PART[11..];
    assert_eq!(
        binary.data.as_deref(),
        Some(tail),
        "tail BINARY fetch must return only the remaining decoded octets"
    );
    assert!(
        tail.len() < 100,
        "the fixture must be shorter than the requested count to exercise truncation"
    );

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

// =========================================================================
// FETCH multiple body sections in one command (RFC 3501 Section 6.4.5)
// =========================================================================

/// Fetch BODY.PEEK[HEADER] and BODY.PEEK[TEXT] simultaneously.
///
/// RFC 3501 Section 6.4.5: A FETCH command may request multiple data items.
/// The server MUST return all requested items in a single untagged FETCH
/// response per message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_multi_body_sections() {
    let conn = connect_dovecot().await;

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

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

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

    // Fetch BODY.PEEK[HEADER] + BODY.PEEK[TEXT] in one FETCH.
    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[
                FetchAttr::Uid,
                FetchAttr::BodySection {
                    peek: true,
                    section: Some("HEADER".into()),
                    partial: None,
                },
                FetchAttr::BodySection {
                    peek: true,
                    section: Some("TEXT".into()),
                    partial: None,
                },
            ],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert!(!fetches.is_empty(), "expected at least one FETCH result");

    let msg = &fetches[0];
    assert!(
        msg.body_sections.len() >= 2,
        "expected at least 2 body sections, got: {}",
        msg.body_sections.len()
    );

    // Find HEADER and TEXT sections.
    let header_section = msg
        .body_sections
        .iter()
        .find(|s| s.section.eq_ignore_ascii_case("HEADER"));
    let text_section = msg
        .body_sections
        .iter()
        .find(|s| s.section.eq_ignore_ascii_case("TEXT"));

    assert!(
        header_section.is_some(),
        "expected BODY[HEADER] in response, sections: {:?}",
        msg.body_sections
            .iter()
            .map(|s| &s.section)
            .collect::<Vec<_>>()
    );
    assert!(
        text_section.is_some(),
        "expected BODY[TEXT] in response, sections: {:?}",
        msg.body_sections
            .iter()
            .map(|s| &s.section)
            .collect::<Vec<_>>()
    );

    let header_data = header_section.unwrap().data.as_ref().expect("HEADER data");
    let text_data = text_section.unwrap().data.as_ref().expect("TEXT data");

    let header_str = String::from_utf8_lossy(header_data);
    let text_str = String::from_utf8_lossy(text_data);

    assert!(
        header_str.contains("Subject:"),
        "HEADER section should contain Subject header"
    );
    assert!(
        text_str.contains("Hello from extended"),
        "TEXT section should contain message body"
    );

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

// =========================================================================
// EXPUNGE renumbering (RFC 3501 Section 7.4.1)
// =========================================================================

/// EXPUNGE must immediately renumber the remaining messages so subsequent
/// sequence-number FETCH results reflect the new numbering.
///
/// RFC 3501 Section 7.4.1: when a message is expunged, the message sequence
/// number for each successive message is immediately decremented by 1.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_expunge_renumbers_remaining_sequence_numbers() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-expunge-renumber");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    for i in 1..=3 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: EXPUNGE renumber {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:0{i} +0000\r\n\
             Message-ID: <expunge-renumber-{i}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             Message {i}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let initial = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(initial.len(), 3, "expected 3 messages before EXPUNGE");

    let first_uid = initial[0].uid.expect("FETCH must include UID");
    let middle_uid = initial[1].uid.expect("FETCH must include UID");
    let last_uid = initial[2].uid.expect("FETCH must include UID");

    conn.uid_store(
        &SequenceSet::new(middle_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();

    match conn.expunge(TIMEOUT).await.unwrap() {
        daaki_imap::types::ExpungeResult::Expunged(seqs) => {
            assert_eq!(seqs, vec![2], "EXPUNGE must report the removed sequence");
        }
        other => panic!("expected classic EXPUNGE response, got {other:?}"),
    }

    let remaining = conn
        .fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(remaining.len(), 2, "expected 2 messages after EXPUNGE");
    assert_eq!(
        (remaining[0].seq, remaining[0].uid),
        (1, Some(first_uid)),
        "the first surviving message must keep sequence number 1"
    );
    assert_eq!(
        (remaining[1].seq, remaining[1].uid),
        (2, Some(last_uid)),
        "the message after the expunged one must shift down to sequence number 2"
    );

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

// =========================================================================
// Pipeline UID SORT / UID THREAD (RFC 5256 Sections 2 and 3)
// =========================================================================

/// `UID SORT` must return UIDs, not sequence numbers, in the requested sort
/// order even after earlier UIDs have been expunged.
///
/// RFC 5256 Section 2 defines UID SORT as the UID variant of SORT.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_sort_returns_uids_in_date_order() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-sort");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    // Seed and expunge one message so the surviving messages' UIDs differ from
    // their current sequence numbers.
    create_uid_gap(&conn, &mbox).await;

    let dated_messages = [
        ("uid-sort-mar", "15 Mar 2026 00:00:00 +0000"),
        ("uid-sort-jan", "10 Jan 2026 00:00:00 +0000"),
        ("uid-sort-jun", "20 Jun 2026 00:00:00 +0000"),
    ];
    for (label, date) in dated_messages {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {label}\r\n\
             Date: {date}\r\n\
             Message-ID: <{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {label}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 3, "expected 3 sortable messages");

    let uid_mar = fetches[0].uid.expect("FETCH must include UID");
    let uid_jan = fetches[1].uid.expect("FETCH must include UID");
    let uid_jun = fetches[2].uid.expect("FETCH must include UID");

    let (sort_result,) = conn
        .pipeline()
        .uid_sort("DATE".to_string(), "UTF-8".to_string(), "ALL".to_string())
        .execute()
        .await
        .unwrap();

    let sort_result = sort_result.unwrap();
    assert_eq!(
        sort_result.ids,
        vec![uid_jan, uid_mar, uid_jun],
        "UID SORT must return UIDs in ascending date order"
    );

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

/// `UID SORT` with `REVERSE DATE` must return UIDs in descending date order.
///
/// RFC 5256 Section 2 defines `REVERSE` as the inverse of the requested sort
/// order and keeps the UID variant in UID space.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_sort_reverse_date_returns_uids_descending() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-sort-reverse");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    create_uid_gap(&conn, &mbox).await;

    let dated_messages = [
        ("uid-sort-rev-mar", "15 Mar 2026 00:00:00 +0000"),
        ("uid-sort-rev-jan", "10 Jan 2026 00:00:00 +0000"),
        ("uid-sort-rev-jun", "20 Jun 2026 00:00:00 +0000"),
    ];
    for (label, date) in dated_messages {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {label}\r\n\
             Date: {date}\r\n\
             Message-ID: <{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {label}\r\n",
            std::process::id()
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 3, "expected 3 sortable messages");

    let uid_mar = fetches[0].uid.expect("FETCH must include UID");
    let uid_jan = fetches[1].uid.expect("FETCH must include UID");
    let uid_jun = fetches[2].uid.expect("FETCH must include UID");

    let (sort_result,) = conn
        .pipeline()
        .uid_sort(
            "REVERSE DATE".to_string(),
            "UTF-8".to_string(),
            "ALL".to_string(),
        )
        .execute()
        .await
        .unwrap();

    let sort_result = sort_result.unwrap();
    assert_eq!(
        sort_result.ids,
        vec![uid_jun, uid_mar, uid_jan],
        "UID SORT REVERSE DATE must return UIDs in descending date order"
    );

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

/// `SORT` on an empty mailbox must return an empty result set.
///
/// RFC 5256 Section 2: SORT returns the sorted set of matching messages; when
/// there are no matches, that set is empty.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_sort_empty_mailbox_returns_empty() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-sort-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (sort_result,) = conn
        .pipeline()
        .sort("DATE".to_string(), "UTF-8".to_string(), "ALL".to_string())
        .execute()
        .await
        .unwrap();

    let sort_result = sort_result.unwrap();
    assert!(
        sort_result.ids.is_empty(),
        "SORT on an empty mailbox must return no message numbers"
    );
    assert!(
        !sort_result.truncated,
        "empty SORT results must not be marked truncated"
    );

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

/// `UID SORT` on an empty mailbox must return an empty result set.
///
/// RFC 5256 Section 2: the UID variant returns the empty UID set when no
/// message matches the search criteria.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_sort_empty_mailbox_returns_empty() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Sort))
    {
        eprintln!("skipping: Dovecot does not advertise SORT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-sort-empty");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let (sort_result,) = conn
        .pipeline()
        .uid_sort("DATE".to_string(), "UTF-8".to_string(), "ALL".to_string())
        .execute()
        .await
        .unwrap();

    let sort_result = sort_result.unwrap();
    assert!(
        sort_result.ids.is_empty(),
        "UID SORT on an empty mailbox must return no UIDs"
    );
    assert!(
        !sort_result.truncated,
        "empty UID SORT results must not be marked truncated"
    );

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

/// `UID THREAD` must return UIDs in the thread tree, not sequence numbers.
///
/// RFC 5256 Section 3 defines UID THREAD as the UID variant of THREAD.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_uid_thread_references_returns_uids() {
    let conn = connect_dovecot().await;

    let has_thread_refs = conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Thread(algo) if algo.eq_ignore_ascii_case("REFERENCES")));
    if !has_thread_refs {
        eprintln!("skipping: Dovecot does not advertise THREAD=REFERENCES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-thread");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    create_uid_gap(&conn, &mbox).await;

    let pid = std::process::id();
    let original = format!(
        "From: alice@example.com\r\n\
         To: bob@example.com\r\n\
         Subject: UID thread test\r\n\
         Date: 10 Jan 2026 00:00:00 +0000\r\n\
         Message-ID: <uid-thread-orig-{pid}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Original message\r\n"
    );
    let reply1 = format!(
        "From: bob@example.com\r\n\
         To: alice@example.com\r\n\
         Subject: Re: UID thread test\r\n\
         Date: 11 Jan 2026 00:00:00 +0000\r\n\
         Message-ID: <uid-thread-reply1-{pid}@daaki>\r\n\
         In-Reply-To: <uid-thread-orig-{pid}@daaki>\r\n\
         References: <uid-thread-orig-{pid}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Reply 1\r\n"
    );
    let reply2 = format!(
        "From: charlie@example.com\r\n\
         To: alice@example.com\r\n\
         Subject: Re: UID thread test\r\n\
         Date: 12 Jan 2026 00:00:00 +0000\r\n\
         Message-ID: <uid-thread-reply2-{pid}@daaki>\r\n\
         In-Reply-To: <uid-thread-orig-{pid}@daaki>\r\n\
         References: <uid-thread-orig-{pid}@daaki>\r\n\
         MIME-Version: 1.0\r\n\
         Content-Type: text/plain; charset=utf-8\r\n\
         \r\n\
         Reply 2\r\n"
    );
    for msg in [&original, &reply1, &reply2] {
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 3, "expected 3 threaded messages");

    let mut expected_uids: Vec<u32> = fetches
        .into_iter()
        .map(|fetch| fetch.uid.expect("UID THREAD seed fetch must include UID"))
        .collect();
    expected_uids.sort_unstable();

    let (threads,) = conn
        .pipeline()
        .uid_thread(
            "REFERENCES".to_string(),
            "UTF-8".to_string(),
            "ALL".to_string(),
        )
        .execute()
        .await
        .unwrap();

    let threads = threads.unwrap();
    assert!(
        !threads.is_empty(),
        "UID THREAD REFERENCES should return at least one thread"
    );

    let mut actual_uids = Vec::new();
    collect_thread_ids(&threads, &mut actual_uids);
    actual_uids.sort_unstable();

    assert_eq!(
        actual_uids, expected_uids,
        "UID THREAD must return message UIDs, not sequence numbers"
    );

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

// =========================================================================
// THREAD / UID THREAD gaps (RFC 5256 Section 3)
// =========================================================================

/// `THREAD ORDEREDSUBJECT` must group messages by base subject and keep every
/// non-root message as a direct child of the thread root.
///
/// RFC 5256 Section 3 defines ORDEREDSUBJECT threading by base subject and
/// states that it has no grandchildren.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_thread_orderedsubject_groups_same_base_subject_without_grandchildren() {
    let conn = connect_dovecot().await;

    if !has_thread_algorithm(&conn, "ORDEREDSUBJECT") {
        eprintln!("skipping: Dovecot does not advertise THREAD=ORDEREDSUBJECT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-thread-orderedsubject-shape");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let pid = std::process::id();
    for spec in [
        ThreadTestMessage {
            from: "alice@example.com",
            to: "bob@example.com",
            subject: "OrderedSubject shape",
            date: "10 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-shape-root-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Root message.\r\n",
        },
        ThreadTestMessage {
            from: "carol@example.com",
            to: "bob@example.com",
            subject: "Re: OrderedSubject shape",
            date: "11 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-shape-child1-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "First sibling.\r\n",
        },
        ThreadTestMessage {
            from: "dave@example.com",
            to: "bob@example.com",
            subject: "Re: OrderedSubject shape",
            date: "12 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-shape-child2-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Second sibling.\r\n",
        },
    ] {
        let message = thread_test_message(spec);
        conn.append(&mbox, &[], None, &message, TIMEOUT)
            .await
            .unwrap();
    }

    let threads = conn
        .thread("ORDEREDSUBJECT", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        threads.len(),
        1,
        "messages sharing one base subject must form exactly one top-level thread"
    );
    assert_eq!(
        threads[0].id,
        Some(1),
        "thread root must be the first message"
    );

    let child_ids: Vec<_> = threads[0].children.iter().map(|node| node.id).collect();
    assert_eq!(
        child_ids,
        vec![Some(2), Some(3)],
        "follow-up ORDEREDSUBJECT messages must be direct children of the root"
    );
    assert!(
        threads[0]
            .children
            .iter()
            .all(|node| node.children.is_empty()),
        "ORDEREDSUBJECT children must not have descendants"
    );

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

/// `THREAD ORDEREDSUBJECT` must sort top-level threads by the sent date of the
/// first message in each thread, not by append order or the newest reply.
///
/// RFC 5256 Section 3 says ORDEREDSUBJECT sorts threads by the sent date of
/// each thread's first message.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_thread_orderedsubject_sorts_threads_by_first_message_date() {
    let conn = connect_dovecot().await;

    if !has_thread_algorithm(&conn, "ORDEREDSUBJECT") {
        eprintln!("skipping: Dovecot does not advertise THREAD=ORDEREDSUBJECT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-thread-orderedsubject-order");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let pid = std::process::id();
    for spec in [
        ThreadTestMessage {
            from: "alice@example.com",
            to: "bob@example.com",
            subject: "Alpha thread",
            date: "20 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-alpha-root-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Alpha root.\r\n",
        },
        ThreadTestMessage {
            from: "alice@example.com",
            to: "bob@example.com",
            subject: "Re: Alpha thread",
            date: "21 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-alpha-child-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Alpha reply.\r\n",
        },
        ThreadTestMessage {
            from: "carol@example.com",
            to: "bob@example.com",
            subject: "Beta thread",
            date: "10 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-beta-root-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Beta root.\r\n",
        },
        ThreadTestMessage {
            from: "carol@example.com",
            to: "bob@example.com",
            subject: "Re: Beta thread",
            date: "25 Jan 2026 00:00:00 +0000",
            message_id: &format!("<orderedsubject-beta-child-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Beta reply.\r\n",
        },
    ] {
        let message = thread_test_message(spec);
        conn.append(&mbox, &[], None, &message, TIMEOUT)
            .await
            .unwrap();
    }

    let threads = conn
        .thread("ORDEREDSUBJECT", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        threads.iter().map(|node| node.id).collect::<Vec<_>>(),
        vec![Some(3), Some(1)],
        "ORDEREDSUBJECT must order top-level threads by their first message date"
    );
    assert_eq!(
        threads[0]
            .children
            .iter()
            .map(|node| node.id)
            .collect::<Vec<_>>(),
        vec![Some(4)],
        "the earlier Beta thread must retain its reply as a direct child"
    );
    assert_eq!(
        threads[1]
            .children
            .iter()
            .map(|node| node.id)
            .collect::<Vec<_>>(),
        vec![Some(2)],
        "the later Alpha thread must retain its reply as a direct child"
    );

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

/// `UID THREAD ORDEREDSUBJECT` must surface stable UIDs while preserving the
/// same no-grandchildren shape as `THREAD ORDEREDSUBJECT`.
///
/// RFC 5256 Section 3 defines UID THREAD as the UID-space variant of THREAD,
/// and ORDEREDSUBJECT still forbids grandchildren.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_thread_orderedsubject_returns_uids_without_grandchildren() {
    let conn = connect_dovecot().await;

    if !has_thread_algorithm(&conn, "ORDEREDSUBJECT") {
        eprintln!("skipping: Dovecot does not advertise THREAD=ORDEREDSUBJECT");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-thread-orderedsubject-shape");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    create_uid_gap(&conn, &mbox).await;

    let pid = std::process::id();
    for spec in [
        ThreadTestMessage {
            from: "alice@example.com",
            to: "bob@example.com",
            subject: "UID OrderedSubject shape",
            date: "10 Jan 2026 00:00:00 +0000",
            message_id: &format!("<uid-orderedsubject-shape-root-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Root message.\r\n",
        },
        ThreadTestMessage {
            from: "carol@example.com",
            to: "bob@example.com",
            subject: "Re: UID OrderedSubject shape",
            date: "11 Jan 2026 00:00:00 +0000",
            message_id: &format!("<uid-orderedsubject-shape-child1-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "First sibling.\r\n",
        },
        ThreadTestMessage {
            from: "dave@example.com",
            to: "bob@example.com",
            subject: "Re: UID OrderedSubject shape",
            date: "12 Jan 2026 00:00:00 +0000",
            message_id: &format!("<uid-orderedsubject-shape-child2-{pid}@daaki>"),
            in_reply_to: None,
            references: None,
            body: "Second sibling.\r\n",
        },
    ] {
        let message = thread_test_message(spec);
        conn.append(&mbox, &[], None, &message, TIMEOUT)
            .await
            .unwrap();
    }

    let expected_uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .into_iter()
        .map(|fetch| fetch.uid.expect("UID THREAD seed fetch must include UID"))
        .collect();
    assert_ne!(
        expected_uids,
        vec![1, 2, 3],
        "setup must create a UID/sequence divergence before UID THREAD"
    );

    let threads = conn
        .uid_thread("ORDEREDSUBJECT", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        threads.len(),
        1,
        "messages sharing one base subject must form exactly one top-level UID thread"
    );
    assert_eq!(
        threads[0].id,
        Some(expected_uids[0]),
        "UID THREAD must use the first message UID at the thread root"
    );
    assert_eq!(
        threads[0]
            .children
            .iter()
            .map(|node| node.id)
            .collect::<Vec<_>>(),
        vec![Some(expected_uids[1]), Some(expected_uids[2])],
        "UID THREAD must surface direct child UIDs for follow-up messages"
    );
    assert!(
        threads[0]
            .children
            .iter()
            .all(|node| node.children.is_empty()),
        "ORDEREDSUBJECT children must not have descendants in UID THREAD either"
    );

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

/// `THREAD REFERENCES` must retain a top-level dummy parent when multiple live
/// messages reference the same missing ancestor.
///
/// RFC 5256 Section 3 creates dummy parents for missing ancestors, and its
/// pruning rules keep that dummy when promoting its children would create
/// multiple top-level siblings.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_thread_references_missing_ancestor_creates_dummy_parent() {
    let conn = connect_dovecot().await;

    if !has_thread_algorithm(&conn, "REFERENCES") {
        eprintln!("skipping: Dovecot does not advertise THREAD=REFERENCES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-thread-dummy-parent");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let pid = std::process::id();
    let missing_parent = format!("<missing-parent-{pid}@daaki>");
    for spec in [
        ThreadTestMessage {
            from: "alice@example.com",
            to: "bob@example.com",
            subject: "Re: Missing ancestor",
            date: "11 Jan 2026 00:00:00 +0000",
            message_id: &format!("<missing-ancestor-child1-{pid}@daaki>"),
            in_reply_to: Some(missing_parent.as_str()),
            references: Some(missing_parent.as_str()),
            body: "First reply.\r\n",
        },
        ThreadTestMessage {
            from: "carol@example.com",
            to: "bob@example.com",
            subject: "Re: Missing ancestor",
            date: "12 Jan 2026 00:00:00 +0000",
            message_id: &format!("<missing-ancestor-child2-{pid}@daaki>"),
            in_reply_to: Some(missing_parent.as_str()),
            references: Some(missing_parent.as_str()),
            body: "Second reply.\r\n",
        },
    ] {
        let message = thread_test_message(spec);
        conn.append(&mbox, &[], None, &message, TIMEOUT)
            .await
            .unwrap();
    }

    let threads = conn
        .thread("REFERENCES", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        threads.len(),
        1,
        "replies to one missing ancestor must remain under one top-level dummy parent"
    );
    assert_eq!(
        threads[0].id, None,
        "missing ancestor must surface as a dummy parent"
    );
    assert_eq!(
        threads[0]
            .children
            .iter()
            .map(|node| node.id)
            .collect::<Vec<_>>(),
        vec![Some(1), Some(2)],
        "the live replies must remain siblings under the dummy parent"
    );
    assert!(
        threads[0]
            .children
            .iter()
            .all(|node| node.children.is_empty()),
        "the dummy parent's live children should not gain extra descendants"
    );

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

/// `UID THREAD REFERENCES` must preserve a dummy parent for a missing
/// ancestor while surfacing stable child UIDs instead of sequence numbers.
///
/// RFC 5256 Section 3 defines UID THREAD as the UID-space variant of THREAD
/// and keeps REFERENCES dummy-parent behavior unchanged.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_thread_references_missing_ancestor_creates_dummy_parent_with_uids() {
    let conn = connect_dovecot().await;

    if !has_thread_algorithm(&conn, "REFERENCES") {
        eprintln!("skipping: Dovecot does not advertise THREAD=REFERENCES");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-thread-dummy-parent");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    create_uid_gap(&conn, &mbox).await;

    let pid = std::process::id();
    let missing_parent = format!("<uid-missing-parent-{pid}@daaki>");
    for spec in [
        ThreadTestMessage {
            from: "alice@example.com",
            to: "bob@example.com",
            subject: "Re: UID missing ancestor",
            date: "11 Jan 2026 00:00:00 +0000",
            message_id: &format!("<uid-missing-ancestor-child1-{pid}@daaki>"),
            in_reply_to: Some(missing_parent.as_str()),
            references: Some(missing_parent.as_str()),
            body: "First UID reply.\r\n",
        },
        ThreadTestMessage {
            from: "carol@example.com",
            to: "bob@example.com",
            subject: "Re: UID missing ancestor",
            date: "12 Jan 2026 00:00:00 +0000",
            message_id: &format!("<uid-missing-ancestor-child2-{pid}@daaki>"),
            in_reply_to: Some(missing_parent.as_str()),
            references: Some(missing_parent.as_str()),
            body: "Second UID reply.\r\n",
        },
    ] {
        let message = thread_test_message(spec);
        conn.append(&mbox, &[], None, &message, TIMEOUT)
            .await
            .unwrap();
    }

    let expected_uids: Vec<u32> = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()
        .into_iter()
        .map(|fetch| fetch.uid.expect("UID THREAD seed fetch must include UID"))
        .collect();
    assert_ne!(
        expected_uids,
        vec![1, 2],
        "setup must create a UID/sequence divergence before UID THREAD"
    );

    let threads = conn
        .uid_thread("REFERENCES", "UTF-8", "ALL", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        threads.len(),
        1,
        "replies to one missing ancestor must remain under one top-level dummy parent"
    );
    assert_eq!(
        threads[0].id, None,
        "missing ancestor must surface as a dummy parent"
    );
    assert_eq!(
        threads[0]
            .children
            .iter()
            .map(|node| node.id)
            .collect::<Vec<_>>(),
        vec![Some(expected_uids[0]), Some(expected_uids[1])],
        "UID THREAD must surface the live reply UIDs under the dummy parent"
    );
    assert!(
        threads[0]
            .children
            .iter()
            .all(|node| node.children.is_empty()),
        "the dummy parent's live children should not gain extra descendants"
    );

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

// =========================================================================
// Pipeline METADATA / ACL gaps (RFC 5464 and RFC 4314)
// =========================================================================

/// Pipelined GETMETADATA must merge multiple requested entries into one
/// typed result and preserve their raw values.
///
/// RFC 5464 Section 4.2 defines GETMETADATA; Section 4.3 defines SETMETADATA.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_metadata_multiple_entries_roundtrip() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Metadata))
    {
        eprintln!("skipping: Dovecot does not advertise METADATA");
        conn.logout().await.unwrap();
        return;
    }

    let mbox_name = unique_name("test-pipe-meta-multi");
    conn.create(&mbox_name, TIMEOUT).await.unwrap();
    let mbox = MailboxName::new(&mbox_name).unwrap();

    let comment_name = "/private/comment".to_owned();
    let second_name = "/private/comment2".to_owned();

    let (set_result, get_result) = conn
        .pipeline()
        .set_metadata(
            mbox.clone(),
            vec![
                (comment_name.clone(), Some(b"value-one".to_vec())),
                (second_name.clone(), Some(b"value-two".to_vec())),
            ],
        )
        .get_metadata(
            mbox.clone(),
            vec![comment_name.clone(), second_name.clone()],
            None,
            None,
        )
        .execute()
        .await
        .unwrap();

    set_result.unwrap();
    let get_result = get_result.unwrap();

    assert!(
        !get_result.notify_ambiguity,
        "GETMETADATA without NOTIFY metadata delivery must be unambiguous"
    );

    let comment = get_result
        .entries
        .iter()
        .find(|entry| entry.name == comment_name)
        .expect("first metadata entry must be present");
    let second = get_result
        .entries
        .iter()
        .find(|entry| entry.name == second_name)
        .expect("second metadata entry must be present");

    assert_eq!(
        comment.value.as_deref(),
        Some(b"value-one".as_slice()),
        "first metadata entry must round-trip"
    );
    assert_eq!(
        second.value.as_deref(),
        Some(b"value-two".as_slice()),
        "second metadata entry must round-trip"
    );

    let (cleanup_result,) = conn
        .pipeline()
        .set_metadata(mbox, vec![(comment_name, None), (second_name, None)])
        .execute()
        .await
        .unwrap();
    cleanup_result.unwrap();

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

// =========================================================================
// Direct metadata edge cases (RFC 5464)
// =========================================================================

/// Renaming a mailbox SHOULD carry its mailbox annotations to the new name.
///
/// RFC 5464 Section 4.1 says servers SHOULD automatically move mailbox
/// annotations when the mailbox they refer to is renamed.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_metadata_rename_moves_mailbox_annotations() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Metadata) {
        eprintln!("skipping: Dovecot does not advertise METADATA");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-meta-rename-src");
    let dst = unique_name("test-meta-rename-dst");
    let value = b"annotation follows mailbox rename";

    conn.create(&src, TIMEOUT).await.unwrap();
    conn.set_metadata(
        &src,
        &[("/private/comment", Some(value.as_slice()))],
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.rename(&src, &dst, TIMEOUT).await.unwrap();

    let renamed = conn
        .get_metadata(&dst, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap();

    conn.set_metadata(&dst, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();
    conn.delete(&dst, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();

    assert!(
        !renamed.notify_ambiguity,
        "GETMETADATA without NOTIFY metadata delivery must be unambiguous"
    );
    assert_eq!(
        metadata_entry_named(&renamed.entries, "/private/comment")
            .and_then(|entry| entry.value.as_deref()),
        Some(value.as_slice()),
        "renamed mailbox must retain its /private/comment annotation"
    );
}

/// Deleting a mailbox SHOULD remove its annotations so a recreated mailbox
/// does not inherit stale metadata.
///
/// RFC 5464 Section 4.1 says servers SHOULD delete mailbox annotations when
/// the mailbox is deleted.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_metadata_delete_recreate_does_not_inherit_old_annotations() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Metadata) {
        eprintln!("skipping: Dovecot does not advertise METADATA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-meta-delete-recreate");
    let stale_value = b"stale annotation must not survive delete";

    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.set_metadata(
        &mailbox,
        &[("/private/comment", Some(stale_value.as_slice()))],
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.create(&mailbox, TIMEOUT).await.unwrap();

    let recreated = conn
        .get_metadata(&mailbox, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap();

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

    assert!(
        !recreated.notify_ambiguity,
        "GETMETADATA without NOTIFY metadata delivery must be unambiguous"
    );
    assert!(
        metadata_entry_named(&recreated.entries, "/private/comment")
            .and_then(|entry| entry.value.as_deref())
            .is_none(),
        "recreated mailbox must not retain /private/comment after deletion: {:?}",
        recreated.entries
    );
}

/// Deleting one metadata entry MUST NOT disturb another entry updated outside
/// that name.
///
/// RFC 5464 Section 4.3 defines `SETMETADATA` as operating on the specified
/// entry/value pairs only; other entries remain unchanged.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_metadata_delete_one_entry_preserves_other_entry() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Metadata) {
        eprintln!("skipping: Dovecot does not advertise METADATA");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-meta-delete-one");
    let private_value = b"private annotation";
    let shared_value = b"shared annotation";

    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.set_metadata(
        &mailbox,
        &[
            ("/private/comment", Some(private_value.as_slice())),
            ("/shared/comment", Some(shared_value.as_slice())),
        ],
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();

    let result = conn
        .get_metadata(
            &mailbox,
            &["/private/comment", "/shared/comment"],
            None,
            None,
            TIMEOUT,
        )
        .await
        .unwrap();

    conn.set_metadata(
        &mailbox,
        &[("/private/comment", None), ("/shared/comment", None)],
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();

    assert!(
        metadata_entry_named(&result.entries, "/private/comment")
            .and_then(|entry| entry.value.as_deref())
            .is_none(),
        "deleted /private/comment must be absent or NIL, got {:?}",
        result.entries
    );
    assert_eq!(
        metadata_entry_named(&result.entries, "/shared/comment")
            .and_then(|entry| entry.value.as_deref()),
        Some(shared_value.as_slice()),
        "deleting /private/comment must not disturb /shared/comment"
    );
}

/// Active mailbox metadata NOTIFY delivery makes `GETMETADATA` results
/// potentially ambiguous.
///
/// RFC 5465 Sections 5.6 and 5.8 reuse the `METADATA` wire form for mailbox
/// metadata notifications, while RFC 5464 Section 4.4 allows solicited
/// `GETMETADATA` to return the same response type. The public API must expose
/// that ambiguity.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_get_metadata_marks_notify_ambiguity_when_metadata_notify_is_active() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("notify-meta-ambiguity-user");
    let conn = connect_dovecot_as(&user).await;

    if !has_capability(&conn, &Capability::Metadata) || !require_dovecot_notify(&conn).await {
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-meta-ambiguity");
    let value = b"ambiguous metadata read";
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.set_metadata(
        &mailbox,
        &[("/private/comment", Some(value.as_slice()))],
        TIMEOUT,
    )
    .await
    .unwrap();

    if !enable_mailbox_metadata_notify(&conn, &mailbox).await {
        conn.set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
            .await
            .unwrap();
        conn.delete(&mailbox, TIMEOUT).await.unwrap();
        conn.logout().await.unwrap();
        return;
    }

    let result = conn
        .get_metadata(&mailbox, &["/private/comment"], None, None, TIMEOUT)
        .await
        .unwrap();

    conn.notify_none(TIMEOUT).await.unwrap();
    conn.set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();
    conn.delete(&mailbox, TIMEOUT).await.unwrap();
    conn.logout().await.unwrap();

    assert!(
        result.notify_ambiguity,
        "GETMETADATA must mark results ambiguous while metadata NOTIFY delivery is active"
    );
    assert_eq!(
        metadata_entry_named(&result.entries, "/private/comment")
            .and_then(|entry| entry.value.as_deref()),
        Some(value.as_slice()),
        "GETMETADATA must still include the requested entry value"
    );
}

// =========================================================================
// Metadata NOTIFY delivery (RFC 5465 Section 5.6 / RFC 5464 Section 4.4.2)
// =========================================================================

/// `next_event()` must surface mailbox metadata change notifications in the
/// typed event queue.
///
/// RFC 5465 Section 5.6 delivers mailbox metadata changes as unsolicited
/// `METADATA` responses, and RFC 5464 Section 4.4.2 says unsolicited
/// `METADATA` responses carry entry names without values.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_next_event_receives_notify_mailbox_metadata_change() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("notify-meta-next-user");
    let watcher = connect_dovecot_as(&user).await;
    let actor = connect_dovecot_as(&user).await;

    if !has_capability(&watcher, &Capability::Metadata) || !require_dovecot_notify(&watcher).await {
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-notify-meta-next");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    if !enable_mailbox_metadata_notify(&watcher, &mailbox).await {
        watcher.delete(&mailbox, TIMEOUT).await.unwrap();
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }
    let _ = watcher.drain_events().await;

    actor
        .set_metadata(
            &mailbox,
            &[("/private/comment", Some(b"notify next_event".as_slice()))],
            TIMEOUT,
        )
        .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 matched_entries = None;
    while tokio::time::Instant::now() < deadline && matched_entries.is_none() {
        if let Some(TypedEvent::Extension(resp)) = watcher
            .next_event(Duration::from_millis(500))
            .await
            .unwrap()
        {
            if let UntaggedResponse::Metadata {
                mailbox: changed_mailbox,
                entries,
            } = resp.as_ref()
            {
                if changed_mailbox.as_str().eq_ignore_ascii_case(&mailbox)
                    && entries
                        .iter()
                        .any(|entry| entry.name.eq_ignore_ascii_case("/private/comment"))
                {
                    matched_entries = Some(entries.clone());
                }
            }
        }
    }

    watcher.notify_none(TIMEOUT).await.unwrap();
    actor
        .set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    actor.logout().await.unwrap();
    watcher.logout().await.unwrap();

    let entries =
        matched_entries.expect("next_event must surface a METADATA event for the changed mailbox");
    let entry = entries
        .iter()
        .find(|entry| entry.name.eq_ignore_ascii_case("/private/comment"))
        .expect("metadata notification must mention /private/comment");
    assert!(
        entry.value.is_none(),
        "unsolicited METADATA notifications must carry entry names without values: {entries:?}"
    );
}

/// `drain_events()` must surface mailbox metadata changes queued by NOTIFY.
///
/// RFC 5465 Section 5.6 delivers mailbox metadata changes as unsolicited
/// `METADATA` responses; the typed event queue must surface those
/// notifications to callers.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_drain_events_receives_notify_mailbox_metadata_change() {
    let _guard = dovecot_notify_test_lock().lock().await;
    let user = unique_name("notify-meta-drain-user");
    let watcher = connect_dovecot_as(&user).await;
    let actor = connect_dovecot_as(&user).await;

    if !has_capability(&watcher, &Capability::Metadata) || !require_dovecot_notify(&watcher).await {
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-notify-meta-drain");
    watcher.create(&mailbox, TIMEOUT).await.unwrap();
    if !enable_mailbox_metadata_notify(&watcher, &mailbox).await {
        watcher.delete(&mailbox, TIMEOUT).await.unwrap();
        actor.logout().await.unwrap();
        watcher.logout().await.unwrap();
        return;
    }
    let _ = watcher.drain_events().await;

    actor
        .set_metadata(
            &mailbox,
            &[("/private/comment", Some(b"notify drain_events".as_slice()))],
            TIMEOUT,
        )
        .await
        .unwrap();

    let events = drain_events_until(
        &watcher,
        tokio::time::Instant::now() + Duration::from_secs(5),
        |event| is_metadata_change_for(event, &mailbox, "/private/comment"),
    )
    .await;

    watcher.notify_none(TIMEOUT).await.unwrap();
    actor
        .set_metadata(&mailbox, &[("/private/comment", None)], TIMEOUT)
        .await
        .unwrap();
    watcher.delete(&mailbox, TIMEOUT).await.unwrap();
    actor.logout().await.unwrap();
    watcher.logout().await.unwrap();

    let entries = events
        .iter()
        .find_map(|event| match event {
            TypedEvent::Extension(resp) => match resp.as_ref() {
                UntaggedResponse::Metadata {
                    mailbox: changed_mailbox,
                    entries,
                } if changed_mailbox.as_str().eq_ignore_ascii_case(&mailbox) => Some(entries),
                _ => None,
            },
            _ => None,
        })
        .expect("drain_events must surface a METADATA event for the changed mailbox");
    let entry = entries
        .iter()
        .find(|entry| entry.name.eq_ignore_ascii_case("/private/comment"))
        .expect("metadata notification must mention /private/comment");
    assert!(
        entry.value.is_none(),
        "unsolicited METADATA notifications must omit values: {entries:?}"
    );
}

// =========================================================================
// Direct SETQUOTA coverage (RFC 9208 Section 4.1)
// =========================================================================

/// Direct `SETQUOTA` must return the same quota view that a follow-up
/// `GETQUOTA` observes for the named root.
///
/// RFC 9208 Section 4.1.1 says successful `SETQUOTA` returns `QUOTA` data for
/// the named root, and Section 4.1.2 defines `GETQUOTA` over the same model.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_set_quota_matches_followup_get_quota() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::QuotaSet) {
        eprintln!("skipping: Dovecot does not advertise QUOTASET");
        conn.logout().await.unwrap();
        return;
    }

    let quota_root = conn.get_quota_root("INBOX", TIMEOUT).await.unwrap();
    if quota_root.roots.is_empty() {
        eprintln!("skipping: Dovecot reported no quota roots for INBOX");
        conn.logout().await.unwrap();
        return;
    }

    let root = quota_root.roots[0].clone();
    let existing = quota_root
        .resources
        .iter()
        .find(|(name, _)| *name == root)
        .map(|(_, resources)| resources.clone())
        .unwrap_or_default();
    if existing.is_empty() {
        eprintln!("skipping: Dovecot returned no quota resources for root {root:?}");
        conn.logout().await.unwrap();
        return;
    }

    let request: Vec<_> = existing
        .iter()
        .map(|resource| (resource.name.as_str(), resource.limit))
        .collect();

    match conn.set_quota(&root, &request, TIMEOUT).await {
        Ok(updated) => {
            let follow_up = conn.get_quota(&root, TIMEOUT).await.unwrap();
            conn.logout().await.unwrap();

            assert_eq!(
                quota_resource_map(&updated),
                quota_resource_map(&follow_up),
                "SETQUOTA and the follow-up GETQUOTA must agree for root {root:?}"
            );
        }
        Err(daaki_imap::Error::No { .. }) => {
            eprintln!("skipping: Dovecot rejected SETQUOTA for the current user");
            conn.logout().await.unwrap();
        }
        Err(err) => panic!("unexpected SETQUOTA failure: {err:?}"),
    }
}

// =========================================================================
// SAVEDATE / PREVIEW direct coverage (RFC 8514 / RFC 8970)
// =========================================================================

/// `FETCH SAVEDATE` must surface the server-reported save timestamp for a
/// newly appended message.
///
/// RFC 8514 Section 3 defines `SAVEDATE` as the date-time when the message
/// was saved to the mailbox.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_savedate() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::SaveDate) {
        eprintln!("skipping: Dovecot does not advertise SAVEDATE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-savedate");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let fetches = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Uid, FetchAttr::SaveDate],
            TIMEOUT,
        )
        .await
        .unwrap();

    assert_eq!(fetches.len(), 1, "expected one FETCH result");
    assert!(
        fetches[0].save_date.is_some(),
        "FETCH SAVEDATE must return a save timestamp for a freshly appended message"
    );

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

/// `UID SEARCH SAVEDSINCE` must match a freshly appended message.
///
/// RFC 8514 Section 4.3 says `SAVEDSINCE <date>` matches messages whose save
/// date is on or after the supplied date.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_savedsince_matches_recent_append() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::SaveDate) {
        eprintln!("skipping: Dovecot does not advertise SAVEDATE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-savedsince");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::SaveDate],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "expected one message in SAVEDSINCE mailbox"
    );
    let uid = fetches[0].uid.expect("UID FETCH must include UID");
    assert!(
        fetches[0].save_date.is_some(),
        "SAVEDSINCE test requires a server-reported SAVEDATE"
    );

    let result = conn
        .uid_search("SAVEDSINCE 1-Jan-2000", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        result.ids,
        vec![uid],
        "UID SEARCH SAVEDSINCE must match the freshly appended message"
    );

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

/// `UID SEARCH SAVEDATESUPPORTED` must match messages in a mailbox whose
/// storage reports save dates.
///
/// RFC 8514 Section 4.3 says `SAVEDATESUPPORTED` matches all messages when the
/// underlying mailbox storage supports the save date attribute.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_savedatesupported_matches_message() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::SaveDate) {
        eprintln!("skipping: Dovecot does not advertise SAVEDATE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-savedatesupported");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::SaveDate],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(
        fetches.len(),
        1,
        "expected one message in SAVEDATESUPPORTED mailbox"
    );
    let uid = fetches[0].uid.expect("UID FETCH must include UID");
    assert!(
        fetches[0].save_date.is_some(),
        "SAVEDATESUPPORTED test requires a server-reported SAVEDATE"
    );

    let result = conn.uid_search("SAVEDATESUPPORTED", TIMEOUT).await.unwrap();
    assert_eq!(
        result.ids,
        vec![uid],
        "UID SEARCH SAVEDATESUPPORTED must match all messages in the mailbox"
    );

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

/// `UID SEARCH SAVEDBEFORE` must exclude a freshly appended message when the
/// cutoff date is far in the past.
///
/// RFC 8514 Section 4.3 says `SAVEDBEFORE <date>` matches only messages whose
/// save date is earlier than the supplied date.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_search_savedbefore_excludes_recent_append() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::SaveDate) {
        eprintln!("skipping: Dovecot does not advertise SAVEDATE");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-savedbefore");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let result = conn
        .uid_search("SAVEDBEFORE 1-Jan-2000", TIMEOUT)
        .await
        .unwrap();
    assert!(
        result.ids.is_empty(),
        "UID SEARCH SAVEDBEFORE must exclude a freshly appended message: {:?}",
        result.ids
    );

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

/// `FETCH PREVIEW` must return non-empty preview text for a non-empty text
/// message body.
///
/// RFC 8970 Section 3 defines `PREVIEW` as a server-generated plaintext
/// snippet derived from the message body.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_preview() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::Preview) {
        eprintln!("skipping: Dovecot does not advertise PREVIEW");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-preview");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();
    conn.append(&mbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

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

    assert_eq!(fetches.len(), 1, "expected one message in preview mailbox");
    let preview = fetches[0]
        .preview
        .as_ref()
        .expect("PREVIEW must return text for a message with a text body");
    assert!(
        !preview.is_empty(),
        "PREVIEW text must be non-empty for a message with body content"
    );

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

/// Pipelined `SETACL`, `DELETEACL`, and `GETACL` must apply in order so the
/// final ACL view no longer contains the deleted identifier.
///
/// RFC 4314 Section 3.1 defines SETACL, Section 3.2 defines DELETEACL, and
/// Section 3.3 defines GETACL.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_pipeline_delete_acl_removes_identifier() {
    let conn = connect_dovecot().await;

    if !conn
        .capabilities()
        .iter()
        .any(|c| matches!(c, Capability::Acl))
    {
        eprintln!("skipping: Dovecot does not advertise ACL");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-pipe-delete-acl");
    conn.create(&mbox, TIMEOUT).await.unwrap();

    let mailbox_name = MailboxName::new(&mbox).unwrap();
    let test_ident = unique_name("acl-ident");

    let (set_result, delete_result, acl_result) = conn
        .pipeline()
        .set_acl(mailbox_name.clone(), test_ident.clone(), "lr".to_string())
        .delete_acl(mailbox_name.clone(), test_ident.clone())
        .get_acl(mailbox_name)
        .execute()
        .await
        .unwrap();

    set_result.unwrap();
    delete_result.unwrap();
    let acl_entries = acl_result.unwrap();

    assert!(
        !acl_entries
            .iter()
            .any(|entry| entry.identifier == test_ident),
        "DELETEACL must remove the identifier from the final GETACL view: {acl_entries:?}"
    );

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

// =========================================================================
// Sequence MOVE result metadata (RFC 6851 Section 4.3)
// =========================================================================

/// `move_messages` must surface COPYUID metadata and expunge notifications for
/// a sequence-number MOVE.
///
/// RFC 6851 Section 3 defines `MOVE` as COPY + EXPUNGE, and RFC 6851 Section
/// 4.3 says the tagged OK response SHOULD carry a `COPYUID` response code when
/// UIDPLUS is available.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_move_by_sequence_returns_copyuid_and_expunged() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|c| matches!(c, Capability::Move)) {
        eprintln!("skipping: Dovecot does not advertise MOVE");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-move-seq-copyuid-src");
    let dst = unique_name("test-move-seq-copyuid-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    for i in 0..2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Move by sequence metadata {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <move-seq-meta-{i}@daaki>\r\n\
             \r\n\
             Body {i}\r\n"
        );
        conn.append(&src, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let move_result = conn
        .move_messages(&SequenceSet::new("1:2").unwrap(), &dst, TIMEOUT)
        .await
        .unwrap();

    if caps.iter().any(|cap| matches!(cap, Capability::UidPlus)) {
        let Some(ResponseCode::CopyUid {
            uid_validity,
            source_uids,
            dest_uids,
        }) = &move_result.code
        else {
            panic!(
                "MOVE must surface COPYUID when the server advertises UIDPLUS: {:?}",
                move_result.code
            );
        };

        assert!(*uid_validity > 0, "COPYUID uid_validity must be positive");
        assert_eq!(
            expand_uid_ranges(source_uids).len(),
            2,
            "COPYUID source_uids must cover the 2 moved messages"
        );
        assert_eq!(
            expand_uid_ranges(dest_uids).len(),
            2,
            "COPYUID dest_uids must cover the 2 created destination messages"
        );
    }

    match &move_result.expunged {
        daaki_imap::types::ExpungeResult::Expunged(seqs) => {
            assert!(
                !seqs.is_empty(),
                "MOVE must report EXPUNGE data for moved messages"
            );
        }
        daaki_imap::types::ExpungeResult::Vanished(ranges) => {
            assert!(
                !ranges.is_empty(),
                "MOVE with QRESYNC must report VANISHED data for moved messages"
            );
        }
        other => panic!("unexpected ExpungeResult variant from MOVE: {other:?}"),
    }

    let selected_src = conn.select(&src, TIMEOUT).await.unwrap();
    assert_eq!(selected_src.exists, 0, "source must be empty after MOVE");

    let selected_dst = conn.select(&dst, TIMEOUT).await.unwrap();
    assert_eq!(
        selected_dst.exists, 2,
        "destination must receive both messages"
    );

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

// =========================================================================
// OBJECTID (RFC 8474)
// =========================================================================

/// FETCH and UID FETCH must surface the same EMAILID and THREADID for the
/// same selected message.
///
/// RFC 8474 Section 5.3 defines EMAILID and THREADID as FETCH data items, and
/// RFC 8474 Sections 5.1 and 5.2 require the same message object to keep the
/// same EMAILID and THREADID regardless of how it is addressed.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_and_uid_fetch_return_same_object_ids_for_same_message() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId) {
        eprintln!("skipping: Dovecot does not advertise OBJECTID");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-objectid-fetch");
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();

    let by_sequence = conn
        .fetch(
            &SequenceSet::new("1").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let by_sequence = single_message_object_ids(&by_sequence);

    let by_uid = conn
        .uid_fetch(
            &SequenceSet::new(by_sequence.uid.to_string()).unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let by_uid = single_message_object_ids(&by_uid);

    assert_eq!(
        by_uid.uid, by_sequence.uid,
        "UID FETCH must return the same UID"
    );
    assert_eq!(
        by_uid.email_id, by_sequence.email_id,
        "FETCH and UID FETCH must agree on EMAILID"
    );
    assert_eq!(
        by_uid.thread_id, by_sequence.thread_id,
        "FETCH and UID FETCH must agree on THREADID"
    );

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

/// COPY by sequence number must preserve EMAILID and THREADID for the copied
/// message.
///
/// RFC 8474 Section 5.1: COPY must preserve EMAILID for the destination
/// message. RFC 8474 Section 5.2: messages sharing an EMAILID must share the
/// same THREADID.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_copy_by_sequence_preserves_emailid_and_threadid() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId) {
        eprintln!("skipping: Dovecot does not advertise OBJECTID");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-objectid-copy-seq-src");
    let dst = unique_name("test-objectid-copy-seq-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.append(&src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    let source = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let source = single_message_object_ids(&source);

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

    conn.select(&dst, TIMEOUT).await.unwrap();
    let copied = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let copied = single_message_object_ids(&copied);

    assert_eq!(
        copied.email_id, source.email_id,
        "COPY must preserve EMAILID for the copied message"
    );
    assert_eq!(
        copied.thread_id, source.thread_id,
        "COPY must preserve THREADID for the copied message"
    );

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

/// UID COPY must preserve EMAILID and THREADID for the copied message.
///
/// RFC 8474 Section 5.1: UID COPY must preserve EMAILID for the destination
/// message. RFC 8474 Section 5.2: messages sharing an EMAILID must share the
/// same THREADID.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_copy_preserves_emailid_and_threadid() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId) {
        eprintln!("skipping: Dovecot does not advertise OBJECTID");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-objectid-copy-uid-src");
    let dst = unique_name("test-objectid-copy-uid-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.append(&src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    let source = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let source = single_message_object_ids(&source);

    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::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let copied = single_message_object_ids(&copied);

    assert_eq!(
        copied.email_id, source.email_id,
        "UID COPY must preserve EMAILID for the copied message"
    );
    assert_eq!(
        copied.thread_id, source.thread_id,
        "UID COPY must preserve THREADID for the copied message"
    );

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

/// MOVE by sequence number must preserve EMAILID and THREADID for the moved
/// message.
///
/// RFC 8474 Section 5.1: MOVE must preserve EMAILID for the destination
/// message. RFC 8474 Section 5.2: messages sharing an EMAILID must share the
/// same THREADID.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_move_by_sequence_preserves_emailid_and_threadid() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId) {
        eprintln!("skipping: Dovecot does not advertise OBJECTID");
        conn.logout().await.unwrap();
        return;
    }
    if !has_capability(&conn, &Capability::Move) {
        eprintln!("skipping: Dovecot does not advertise MOVE");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-objectid-move-seq-src");
    let dst = unique_name("test-objectid-move-seq-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.append(&src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    let source = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let source = single_message_object_ids(&source);

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

    conn.select(&dst, TIMEOUT).await.unwrap();
    let moved = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let moved = single_message_object_ids(&moved);

    assert_eq!(
        moved.email_id, source.email_id,
        "MOVE must preserve EMAILID for the moved message"
    );
    assert_eq!(
        moved.thread_id, source.thread_id,
        "MOVE must preserve THREADID for the moved message"
    );

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

/// UID MOVE must preserve EMAILID and THREADID for the moved message.
///
/// RFC 8474 Section 5.1: UID MOVE must preserve EMAILID for the destination
/// message. RFC 8474 Section 5.2: messages sharing an EMAILID must share the
/// same THREADID.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_move_preserves_emailid_and_threadid() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId) {
        eprintln!("skipping: Dovecot does not advertise OBJECTID");
        conn.logout().await.unwrap();
        return;
    }
    if !has_capability(&conn, &Capability::Move) {
        eprintln!("skipping: Dovecot does not advertise MOVE");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-objectid-move-uid-src");
    let dst = unique_name("test-objectid-move-uid-dst");
    conn.create(&src, TIMEOUT).await.unwrap();
    conn.create(&dst, TIMEOUT).await.unwrap();
    conn.append(&src, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&src, TIMEOUT).await.unwrap();

    let source = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let source = single_message_object_ids(&source);

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

    conn.select(&dst, TIMEOUT).await.unwrap();
    let moved = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid, FetchAttr::EmailId, FetchAttr::ThreadId],
            TIMEOUT,
        )
        .await
        .unwrap();
    let moved = single_message_object_ids(&moved);

    assert_eq!(
        moved.email_id, source.email_id,
        "UID MOVE must preserve EMAILID for the moved message"
    );
    assert_eq!(
        moved.thread_id, source.thread_id,
        "UID MOVE must preserve THREADID for the moved message"
    );

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

/// RENAME must preserve MAILBOXID for the renamed mailbox.
///
/// RFC 8474 Section 4: renaming a mailbox keeps the same mailbox object, so
/// the destination mailbox must report the same MAILBOXID as the source.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_rename_preserves_mailboxid() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId) {
        eprintln!("skipping: Dovecot does not advertise OBJECTID");
        conn.logout().await.unwrap();
        return;
    }

    let src = unique_name("test-mailboxid-rename-src");
    let dst = unique_name("test-mailboxid-rename-dst");
    conn.create(&src, TIMEOUT).await.unwrap();

    let before = conn.status(&src, "(MAILBOXID)", TIMEOUT).await.unwrap();
    let before = status_mailbox_id(&before.items)
        .expect("OBJECTID-capable server must return MAILBOXID for STATUS")
        .to_owned();

    conn.rename(&src, &dst, TIMEOUT).await.unwrap();

    let after = conn.status(&dst, "(MAILBOXID)", TIMEOUT).await.unwrap();
    let after = status_mailbox_id(&after.items)
        .expect("renamed mailbox must still report MAILBOXID")
        .to_owned();

    assert_eq!(after, before, "RENAME must preserve MAILBOXID");

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

/// LIST-STATUS with MAILBOXID must report the same mailbox object identifier
/// as STATUS for the same mailbox.
///
/// RFC 5819 Section 2 defines LIST RETURN (STATUS ...), and RFC 8474 Sections
/// 4.1 and 4.3 require OBJECTID-capable servers to expose the same MAILBOXID
/// for a mailbox via CREATE and STATUS.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_list_status_mailboxid_matches_status_mailboxid() {
    let conn = connect_dovecot().await;

    if !has_capability(&conn, &Capability::ObjectId)
        || !has_capability(&conn, &Capability::ListStatus)
        || !has_capability(&conn, &Capability::ListExtended)
    {
        eprintln!("skipping: Dovecot must advertise OBJECTID, LIST-STATUS, and LIST-EXTENDED");
        conn.logout().await.unwrap();
        return;
    }

    let mailbox = unique_name("test-list-status-mailboxid");
    let created = conn
        .create_with_mailbox_id(&mailbox, TIMEOUT)
        .await
        .unwrap()
        .expect("OBJECTID-capable server must return MAILBOXID on CREATE");

    let status = conn.status(&mailbox, "(MAILBOXID)", TIMEOUT).await.unwrap();
    let status_mailbox_id_value = status_mailbox_id(&status.items)
        .expect("STATUS MAILBOXID must be present for the created mailbox")
        .to_owned();

    let listing = conn
        .list_status("", &mailbox, "(MAILBOXID)", TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        listing.len(),
        1,
        "LIST-STATUS with an exact pattern must return exactly one mailbox"
    );
    assert_eq!(listing[0].0.name.as_str(), mailbox);

    let list_status_mailbox_id = status_mailbox_id(&listing[0].1)
        .expect("LIST-STATUS MAILBOXID must be present for the created mailbox");

    assert_eq!(
        status_mailbox_id_value, created,
        "STATUS MAILBOXID must match the CREATE response code"
    );
    assert_eq!(
        list_status_mailbox_id, created,
        "LIST-STATUS MAILBOXID must match the CREATE response code"
    );

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

// =========================================================================
// Streaming FETCH postconditions (RFC 3501 Section 6.4.5)
// =========================================================================

/// `fetch_streaming` must leave the connection usable for a subsequent regular
/// FETCH on the same selected mailbox.
///
/// RFC 3501 Section 6.4.5 defines FETCH as a normal selected-state command; a
/// completed FETCH must not leave the session in a broken state.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_fetch_streaming_leaves_connection_usable() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-fetch-stream-reuse");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    for i in 0..2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: Fetch streaming reuse {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <fetch-stream-reuse-{i}@daaki>\r\n\
             \r\n\
             Body {i}\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    let seq = SequenceSet::new("1:*").unwrap();
    conn.fetch_streaming(&seq, &[FetchAttr::Uid, FetchAttr::Flags], tx, TIMEOUT)
        .await
        .unwrap();

    let mut streamed = Vec::new();
    while let Some(item) = rx.recv().await {
        streamed.push(item.unwrap());
    }
    assert_eq!(streamed.len(), 2, "expected 2 streamed FETCH responses");

    let fetched = conn
        .fetch(&seq, &[FetchAttr::Uid, FetchAttr::Flags], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        fetched.len(),
        2,
        "regular FETCH must still work after fetch_streaming"
    );

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

/// `uid_fetch_streaming` must leave the connection usable for a subsequent
/// regular UID FETCH on the same selected mailbox.
///
/// RFC 3501 Section 6.4.5 applies equally to UID FETCH: after the streaming
/// fetch completes, the selected mailbox state must remain usable.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_streaming_leaves_connection_usable() {
    let conn = connect_dovecot().await;

    let mbox = unique_name("test-uid-fetch-stream-reuse");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    for i in 0..2 {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: UID fetch streaming reuse {i}\r\n\
             Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
             Message-ID: <uid-fetch-stream-reuse-{i}@daaki>\r\n\
             \r\n\
             Body {i}\r\n"
        );
        conn.append(&mbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

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

    let (tx, mut rx) = tokio::sync::mpsc::channel(16);
    let uid_set = SequenceSet::new("1:*").unwrap();
    conn.uid_fetch_streaming(&uid_set, &[FetchAttr::Uid, FetchAttr::Flags], tx, TIMEOUT)
        .await
        .unwrap();

    let mut streamed = Vec::new();
    while let Some(item) = rx.recv().await {
        streamed.push(item.unwrap());
    }
    assert_eq!(streamed.len(), 2, "expected 2 streamed UID FETCH responses");

    let fetched = conn
        .uid_fetch(&uid_set, &[FetchAttr::Uid, FetchAttr::Flags], TIMEOUT)
        .await
        .unwrap();
    assert_eq!(
        fetched.len(),
        2,
        "regular UID FETCH must still work after uid_fetch_streaming"
    );

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

// =========================================================================
// QRESYNC preconditions (RFC 7162 Section 3.2.6)
// =========================================================================

/// `uid_fetch_vanished` must reject use until QRESYNC has been ENABLEd.
///
/// RFC 7162 Section 3.2.6 requires the `VANISHED` modifier to be used only
/// after QRESYNC has been enabled for the connection.
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn dovecot_uid_fetch_vanished_requires_qresync_enabled() {
    let conn = connect_dovecot().await;

    let caps = conn.capabilities();
    if !caps.iter().any(|cap| matches!(cap, Capability::QResync)) {
        eprintln!("skipping: Dovecot does not advertise QRESYNC");
        conn.logout().await.unwrap();
        return;
    }

    let mbox = unique_name("test-uid-fetch-vanished-no-enable");
    conn.create(&mbox, TIMEOUT).await.unwrap();
    conn.select(&mbox, TIMEOUT).await.unwrap();

    let result = conn
        .uid_fetch_vanished(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            1,
            TIMEOUT,
        )
        .await;
    assert!(
        matches!(result, Err(daaki_imap::Error::MissingCapability(ref cap)) if cap.contains("QRESYNC")),
        "expected Error::MissingCapability containing \"QRESYNC\", got {result:?}"
    );

    conn.close(TIMEOUT).await.unwrap();
    conn.delete(&mbox, 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(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    conn.login(USER, 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(
        HOST,
        IMAP_PORT,
        TlsMode::None,
        tls_config.clone(),
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.starttls_with_config(tls_config, TIMEOUT)
        .await
        .unwrap();
    conn.login(user, PASS, TIMEOUT).await.unwrap();
    DovecotConnection {
        conn,
        _permit: permit,
    }
}

/// Create, populate, and select a fresh mailbox for selected-state tests.
///
/// RFC 3501 Section 6.3.3 creates the mailbox, Section 6.3.11 appends one
/// message to it, and Section 6.3.1 selects it.
async fn prepare_selected_mailbox(conn: &ImapConnection, prefix: &str) -> String {
    let mailbox = unique_name(prefix);
    conn.create(&mailbox, TIMEOUT).await.unwrap();
    conn.append(&mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    conn.select(&mailbox, TIMEOUT).await.unwrap();
    mailbox
}

/// Assert that the current mailbox remained selected and usable.
///
/// RFC 3501 Section 6.4 requires commands that are valid in selected state to
/// leave the current mailbox selected for follow-up commands.
async fn assert_selected_mailbox_still_usable(conn: &ImapConnection, command: &str) {
    assert_eq!(
        conn.session_state(),
        SessionState::Selected,
        "{command} must not leave selected state"
    );
    assert_eq!(
        conn.search("ALL", TIMEOUT).await.unwrap().ids,
        vec![1],
        "selected mailbox must remain usable after {command}"
    );
}

/// Generate a mailbox or username suffix unique across concurrent ignored tests.
fn unique_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())
}

/// Check whether the server advertised a specific extension capability.
fn has_capability(conn: &ImapConnection, cap: &Capability) -> bool {
    conn.capabilities().iter().any(|candidate| candidate == cap)
}

/// RFC 5256 Section 1 advertises threading support as `THREAD=<algorithm>`.
fn has_thread_algorithm(conn: &ImapConnection, algorithm: &str) -> bool {
    conn.capabilities().iter().any(
        |candidate| matches!(candidate, Capability::Thread(name) if name.eq_ignore_ascii_case(algorithm)),
    )
}

/// Discover the server's hierarchy delimiter via LIST (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)
        .unwrap_or('/')
}

/// Extract a single UID from a UID set represented as exactly one range.
fn single_uid_from_ranges(ranges: &[UidRange]) -> u32 {
    assert_eq!(
        ranges.len(),
        1,
        "expected exactly one UID range, got {ranges:?}"
    );
    let range = &ranges[0];
    assert!(
        range.end.is_none() || range.end == Some(range.start),
        "expected a single UID range, got {range:?}"
    );
    range.start
}

/// Expand an IMAP `sequence-set`/`uid-set` represented with [`UidRange`] into
/// concrete ids for exact comparisons in tests.
fn expand_uid_ranges(ranges: &[UidRange]) -> Vec<u32> {
    let mut ids = Vec::new();
    for range in ranges {
        match range.end {
            Some(end) => ids.extend(range.start..=end),
            None => ids.push(range.start),
        }
    }
    ids
}

/// Extract the `MESSAGES` count from a LIST-STATUS result.
fn status_messages(items: &[daaki_imap::types::StatusItem]) -> Option<u32> {
    items.iter().find_map(|item| match item {
        daaki_imap::types::StatusItem::Messages(count) => Some(*count),
        _ => None,
    })
}

/// Extract the `UIDNEXT` value from a LIST-STATUS result.
fn status_uidnext(items: &[daaki_imap::types::StatusItem]) -> Option<u32> {
    items.iter().find_map(|item| match item {
        daaki_imap::types::StatusItem::UidNext(next) => Some(*next),
        _ => None,
    })
}

/// Extract the `MAILBOXID` object identifier from STATUS-style results.
///
/// RFC 8474 Section 4.3 defines MAILBOXID as a STATUS data item.
fn status_mailbox_id(items: &[daaki_imap::types::StatusItem]) -> Option<&str> {
    items.iter().find_map(|item| match item {
        daaki_imap::types::StatusItem::MailboxId(mailbox_id) => Some(mailbox_id.as_str()),
        _ => None,
    })
}

/// RFC 9208 Sections 4.1.1 and 4.2.1 describe QUOTA as a set of named
/// `(usage, limit)` resource triplets for a single quota root.
fn quota_resource_map(resources: &[QuotaResource]) -> BTreeMap<String, (u64, u64)> {
    resources
        .iter()
        .map(|resource| (resource.name.clone(), (resource.usage, resource.limit)))
        .collect()
}

/// RFC 4314 Section 3.6 models ACL as identifier/rights pairs.
fn acl_rights_for<'a>(entries: &'a [AclEntry], identifier: &str) -> Option<&'a str> {
    entries
        .iter()
        .find(|entry| entry.identifier == identifier)
        .map(|entry| entry.rights.as_str())
}

/// RFC 4314 Sections 3.1 and 3.7 represent rights as sets of characters.
fn rights_char_set(rights: &str) -> BTreeSet<char> {
    rights.chars().collect()
}

/// RFC 4314 Section 3.7 allows rights to be tied in groups; for the additive
/// and subtractive SETACL tests we prefer non-virtual groups to avoid `c`/`d`
/// compatibility aliases from Section 2.1.1.
fn pick_one_non_virtual_acl_group(rights: &ListRightsResponse) -> Option<String> {
    rights
        .optional
        .iter()
        .find(|group| !group.is_empty() && !group.chars().any(|ch| matches!(ch, 'c' | 'd')))
        .cloned()
}

/// RFC 4314 Section 3.7 says rights in the same string are tied together, so
/// the additive and subtractive SETACL tests need two disjoint groups.
fn pick_two_non_virtual_acl_groups(rights: &ListRightsResponse) -> Option<(String, String)> {
    let candidates: Vec<_> = rights
        .optional
        .iter()
        .filter(|group| !group.is_empty() && !group.chars().any(|ch| matches!(ch, 'c' | 'd')))
        .cloned()
        .collect();

    for (index, first) in candidates.iter().enumerate() {
        let first_chars = rights_char_set(first);
        for second in candidates.iter().skip(index + 1) {
            if first_chars.is_disjoint(&rights_char_set(second)) {
                return Some((first.clone(), second.clone()));
            }
        }
    }

    None
}

/// Object identifiers surfaced by FETCH for a single message.
///
/// RFC 3501 Section 2.3.1.1 defines UID, and RFC 8474 Sections 5.1 through
/// 5.3 define EMAILID and THREADID metadata visible in FETCH responses.
#[derive(Debug, Clone, PartialEq, Eq)]
struct MessageObjectIds {
    uid: u32,
    email_id: String,
    thread_id: Option<String>,
}

/// Extract the single message's UID, EMAILID, and THREADID from a one-message
/// FETCH result.
///
/// RFC 8474 Section 5.3 defines EMAILID and THREADID as FETCH data items for a
/// message object.
fn single_message_object_ids(fetches: &[daaki_imap::types::FetchResponse]) -> MessageObjectIds {
    assert_eq!(
        fetches.len(),
        1,
        "expected exactly one fetched message, got {fetches:?}"
    );
    let fetch = &fetches[0];
    let uid = fetch.uid.expect("FETCH must include UID when requested");
    let email_id = fetch
        .email_id
        .clone()
        .expect("OBJECTID-capable server must return EMAILID when requested");
    assert!(!email_id.is_empty(), "EMAILID must not be empty");
    if let Some(thread_id) = fetch.thread_id.as_deref() {
        assert!(
            !thread_id.is_empty(),
            "THREADID must not be empty when present"
        );
    }
    MessageObjectIds {
        uid,
        email_id,
        thread_id: fetch.thread_id.clone(),
    }
}

/// Append and expunge a single message to ensure subsequent APPENDs receive
/// UIDs that no longer match their sequence numbers.
async fn create_uid_gap(conn: &ImapConnection, mailbox: &str) {
    conn.append(mailbox, &[], None, TEST_MSG, TIMEOUT)
        .await
        .unwrap();
    let seed_uid = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap()[0]
        .uid
        .expect("seed FETCH must include UID");
    conn.uid_store(
        &SequenceSet::new(seed_uid.to_string()).unwrap(),
        StoreOperation::Add,
        &[Flag::Deleted],
        None,
        TIMEOUT,
    )
    .await
    .unwrap();
    conn.expunge(TIMEOUT).await.unwrap();
}

/// Append three messages whose `Date` headers are intentionally out of append
/// order so RFC 5256 Section 2 SORT tests can distinguish sequence ordering
/// from stable UID ordering.
async fn append_sort_fixture(conn: &ImapConnection, mailbox: &str, prefix: &str) -> [u32; 3] {
    let dated_messages = [
        ("mar", "15 Mar 2026 00:00:00 +0000"),
        ("jan", "10 Jan 2026 00:00:00 +0000"),
        ("jun", "20 Jun 2026 00:00:00 +0000"),
    ];
    for (label, date) in dated_messages {
        let msg = format!(
            "From: sender@example.com\r\n\
             To: recipient@example.com\r\n\
             Subject: {prefix}-{label}\r\n\
             Date: {date}\r\n\
             Message-ID: <{prefix}-{label}-{}@daaki>\r\n\
             MIME-Version: 1.0\r\n\
             Content-Type: text/plain; charset=utf-8\r\n\
             \r\n\
             {prefix}-{label}\r\n",
            std::process::id()
        );
        conn.append(mailbox, &[], None, msg.as_bytes(), TIMEOUT)
            .await
            .unwrap();
    }

    let fetches = conn
        .uid_fetch(
            &SequenceSet::new("1:*").unwrap(),
            &[FetchAttr::Uid],
            TIMEOUT,
        )
        .await
        .unwrap();
    assert_eq!(fetches.len(), 3, "expected 3 sortable messages");

    [
        fetches[0].uid.expect("SORT fixture FETCH must include UID"),
        fetches[1].uid.expect("SORT fixture FETCH must include UID"),
        fetches[2].uid.expect("SORT fixture FETCH must include UID"),
    ]
}

/// RFC 9051 Appendix E folds SEARCHRES into IMAP4rev2, so either
/// advertisement is sufficient for `$`-based pipeline SEARCH SAVE tests.
fn supports_searchres(conn: &ImapConnection) -> bool {
    conn.capabilities()
        .iter()
        .any(|cap| matches!(cap, Capability::SearchRes | Capability::Imap4Rev2))
}

/// Collect all concrete node identifiers from a THREAD tree.
fn collect_thread_ids(nodes: &[ThreadNode], ids: &mut Vec<u32>) {
    for node in nodes {
        if let Some(id) = node.id {
            ids.push(id);
        }
        collect_thread_ids(&node.children, ids);
    }
}

/// 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)
}

// =========================================================================
// Raw-protocol server behavior tests
//
// These bypass the client library's state machine and speak raw IMAP over
// TCP, verifying that the server itself correctly rejects commands in the
// wrong state. This complements the client-side state enforcement tested
// in unit tests.
// =========================================================================

mod raw_protocol {
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
    use tokio::net::TcpStream;
    use tokio_rustls::TlsConnector;

    const HOST: &str = "127.0.0.1";
    const IMAPS_PORT: u16 = 14993;
    const USER: &str = "testuser";
    const PASS: &str = "testpass";

    /// A minimal raw IMAP-over-TLS connection for testing server behavior.
    /// Uses implicit TLS on port 14993 to bypass STARTTLS complexity.
    struct RawImap {
        reader: BufReader<tokio::io::ReadHalf<tokio_rustls::client::TlsStream<TcpStream>>>,
        writer: tokio::io::WriteHalf<tokio_rustls::client::TlsStream<TcpStream>>,
        tag: u32,
    }

    impl RawImap {
        async fn connect() -> Self {
            let tls_config = super::build_danger_tls_config();
            let connector = TlsConnector::from(tls_config);
            let tcp = TcpStream::connect((HOST, IMAPS_PORT)).await.unwrap();
            let server_name = rustls_pki_types::ServerName::try_from(HOST.to_string()).unwrap();
            let tls_stream = connector.connect(server_name, tcp).await.unwrap();
            let (read_half, write_half) = tokio::io::split(tls_stream);
            let mut raw = Self {
                reader: BufReader::new(read_half),
                writer: write_half,
                tag: 0,
            };
            // Consume greeting
            let greeting = raw.read_line().await;
            assert!(
                greeting.starts_with("* OK") || greeting.starts_with("* PREAUTH"),
                "unexpected greeting: {greeting}"
            );
            raw
        }

        async fn connect_and_login() -> Self {
            let mut raw = Self::connect().await;
            let resp = raw.command(&format!("LOGIN {USER} {PASS}")).await;
            assert!(Self::status(&resp) == "OK", "LOGIN failed: {resp}");
            raw
        }

        async fn connect_login_and_select() -> Self {
            let mut raw = Self::connect_and_login().await;
            let resp = raw.command("SELECT INBOX").await;
            assert!(Self::status(&resp) == "OK", "SELECT failed: {resp}");
            raw
        }

        async fn command(&mut self, cmd: &str) -> String {
            self.tag += 1;
            let tag = format!("T{:03}", self.tag);
            let line = format!("{tag} {cmd}\r\n");
            self.writer.write_all(line.as_bytes()).await.unwrap();
            // Read until we get the tagged response
            let mut response = String::new();
            loop {
                let line = self.read_line().await;
                let done = line.starts_with(&tag);
                response.push_str(&line);
                if done {
                    break;
                }
            }
            response
        }

        async fn read_line(&mut self) -> String {
            let mut line = String::new();
            tokio::time::timeout(
                std::time::Duration::from_secs(5),
                self.reader.read_line(&mut line),
            )
            .await
            .expect("read timeout")
            .expect("read error");
            line
        }

        /// Extract the tagged status (OK, NO, BAD) from a response.
        fn status(response: &str) -> &str {
            let last = response.lines().last().unwrap_or("");
            if let Some(rest) = last.split_once(' ').map(|(_, r)| r) {
                if rest.starts_with("OK") {
                    "OK"
                } else if rest.starts_with("NO") {
                    "NO"
                } else if rest.starts_with("BAD") {
                    "BAD"
                } else {
                    "UNKNOWN"
                }
            } else {
                "UNKNOWN"
            }
        }
    }

    // ----- Authenticated state: commands requiring Selected -----

    /// Server rejects CLOSE in Authenticated state (not Selected).
    /// RFC 3501 Section 6.4.2.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_close_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("CLOSE").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject CLOSE without SELECT: {resp}"
        );
    }

    /// Server rejects EXPUNGE in Authenticated state.
    /// RFC 3501 Section 6.4.3.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_expunge_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("EXPUNGE").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject EXPUNGE without SELECT: {resp}"
        );
    }

    /// Server rejects SEARCH in Authenticated state.
    /// RFC 3501 Section 6.4.4.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_search_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("SEARCH ALL").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject SEARCH without SELECT: {resp}"
        );
    }

    /// Server rejects FETCH in Authenticated state.
    /// RFC 3501 Section 6.4.5.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_fetch_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("FETCH 1 (FLAGS)").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject FETCH without SELECT: {resp}"
        );
    }

    /// Server rejects STORE in Authenticated state.
    /// RFC 3501 Section 6.4.6.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_store_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("STORE 1 +FLAGS (\\Seen)").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject STORE without SELECT: {resp}"
        );
    }

    /// Server rejects COPY in Authenticated state.
    /// RFC 3501 Section 6.4.7.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_copy_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("COPY 1 INBOX").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject COPY without SELECT: {resp}"
        );
    }

    /// Server rejects MOVE in Authenticated state.
    /// RFC 6851.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_move_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("MOVE 1 INBOX").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject MOVE without SELECT: {resp}"
        );
    }

    /// Server rejects UNSELECT in Authenticated state.
    /// RFC 3691.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_unselect_without_select() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("UNSELECT").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject UNSELECT without SELECT: {resp}"
        );
    }

    // ----- Not Authenticated state: commands requiring auth -----

    /// Server rejects SELECT before authentication.
    /// RFC 3501 Section 6.3.1.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_select_before_auth() {
        let mut raw = RawImap::connect().await;
        let resp = raw.command("SELECT INBOX").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject SELECT before auth: {resp}"
        );
    }

    /// Server rejects CREATE before authentication.
    /// RFC 3501 Section 6.3.3.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_create_before_auth() {
        let mut raw = RawImap::connect().await;
        let resp = raw.command("CREATE testbox").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject CREATE before auth: {resp}"
        );
    }

    /// Server rejects NAMESPACE before authentication.
    /// RFC 2342 Section 5.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_rejects_namespace_before_auth() {
        let mut raw = RawImap::connect().await;
        let resp = raw.command("NAMESPACE").await;
        assert!(
            matches!(RawImap::status(&resp), "BAD" | "NO"),
            "server should reject NAMESPACE before auth: {resp}"
        );
    }

    // ----- Logout state: server closes connection -----

    /// Server closes connection after LOGOUT.
    /// RFC 3501 Section 6.1.3: server MUST send BYE then close.
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_closes_after_logout() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("LOGOUT").await;
        assert!(resp.contains("BYE"), "LOGOUT must trigger BYE: {resp}");
        assert_eq!(RawImap::status(&resp), "OK");
        // After LOGOUT + BYE, the server closes the TLS connection.
        // A read should return EOF (empty string) or error.
        let result = tokio::time::timeout(std::time::Duration::from_secs(2), async {
            let mut buf = String::new();
            raw.reader.read_line(&mut buf).await.map(|_| buf)
        })
        .await;
        match result {
            Err(_) => {} // timeout = connection closed, acceptable
            Ok(Ok(buf)) => {
                // EOF returns Ok(0) => empty string
                assert!(buf.is_empty(), "expected EOF after LOGOUT, got: {buf}");
            }
            Ok(Err(_)) => {} // I/O error = connection closed, good
        }
    }

    // ----- Commands that should succeed in the right state -----

    /// Server accepts NOOP in any state (RFC 3501 Section 6.1.2).
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_accepts_noop_before_auth() {
        let mut raw = RawImap::connect().await;
        let resp = raw.command("NOOP").await;
        assert_eq!(
            RawImap::status(&resp),
            "OK",
            "NOOP should work before auth: {resp}"
        );
    }

    /// Server accepts CAPABILITY in any state (RFC 3501 Section 6.1.1).
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_accepts_capability_before_auth() {
        let mut raw = RawImap::connect().await;
        let resp = raw.command("CAPABILITY").await;
        assert_eq!(
            RawImap::status(&resp),
            "OK",
            "CAPABILITY should work before auth: {resp}"
        );
        assert!(
            resp.contains("IMAP4rev1"),
            "should report IMAP4rev1: {resp}"
        );
    }

    /// Server accepts ID in any state (RFC 2971 Section 3.1).
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_accepts_id_before_auth() {
        let mut raw = RawImap::connect().await;
        let resp = raw.command("ID NIL").await;
        assert_eq!(
            RawImap::status(&resp),
            "OK",
            "ID should work before auth: {resp}"
        );
    }

    /// Server accepts LIST in Authenticated state (RFC 3501 Section 6.3.8).
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_accepts_list_in_authenticated() {
        let mut raw = RawImap::connect_and_login().await;
        let resp = raw.command("LIST \"\" \"*\"").await;
        assert_eq!(
            RawImap::status(&resp),
            "OK",
            "LIST should work in Authenticated: {resp}"
        );
    }

    /// Server accepts CLOSE after SELECT (RFC 3501 Section 6.4.2).
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_accepts_close_after_select() {
        let mut raw = RawImap::connect_login_and_select().await;
        let resp = raw.command("CLOSE").await;
        assert_eq!(
            RawImap::status(&resp),
            "OK",
            "CLOSE should work after SELECT: {resp}"
        );
    }

    /// Server accepts SEARCH after SELECT (RFC 3501 Section 6.4.4).
    #[tokio::test]
    #[ignore = "requires Docker: docker compose up -d"]
    async fn server_accepts_search_after_select() {
        let mut raw = RawImap::connect_login_and_select().await;
        let resp = raw.command("SEARCH ALL").await;
        assert_eq!(
            RawImap::status(&resp),
            "OK",
            "SEARCH should work after SELECT: {resp}"
        );
    }
}
