//! Invariant tests for the IMAP rewrite plan.
//!
//! ## Bug inventory (B1–B10)
//!
//! Each bug test documents a bug from the bug inventory in `IMAP_REWRITE_PLAN.md`
//! and asserts what the *correct* behavior should be. These tests are expected
//! to **fail** against the current codebase — that failure is the proof that
//! the bug exists. Each test will pass after its corresponding Phase 1 fix
//! lands.
//!
//! ## Invariant I7 — Cancellation safety (Phase 5l)
//!
//! Tests that dropping any future returned by a public `&self` method at an
//! `.await` point leaves the connection usable for the next command. The
//! driver-task architecture (Phase 5) guarantees this: the caller-side future
//! only holds a `oneshot::Receiver` — dropping it abandons the result but
//! the driver completes the in-flight command normally.
//!
//! ## Driver graceful shutdown (Phase 5l)
//!
//! Tests that after `logout()` the driver exits cleanly and subsequent
//! commands observe `Error::DriverGone` without blocking.
//!
//! # Convention
//!
//! - Bug tests: `invariant_BN_<short_description>`
//! - Cancellation tests: `invariant_I7_cancellation_safety_<method>`
//! - Driver tests: `invariant_driver_<scenario>`
//! - Each test documents which phase fixes/verifies the behavior
//!
//! # Structure
//!
//! Each test spawns the mock server script on a separate task so mock
//! I/O and client I/O proceed concurrently, avoiding sequential timeouts.

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

mod support;

use std::time::Duration;

use daaki_imap::TypedEvent;
use support::mock_connection;

const TIMEOUT: Duration = Duration::from_secs(5);
const SHORT: Duration = Duration::from_millis(500);

// ---------------------------------------------------------------------------
// B1 — logout() drops non-BYE untagged responses (including ALERTs)
// Fixed in: Phase 1d
// ---------------------------------------------------------------------------

/// Bug: `auth.rs` `logout()` has a hand-rolled reader loop that only checks
/// for BYE among untagged responses. All other untagged responses — including
/// `* OK [ALERT] ...` — are silently dropped. RFC 3501 Section 7.1 mandates
/// that ALERTs "MUST be presented to the user."
///
/// Correct behavior: after `logout()` completes, any ALERT received during
/// the LOGOUT exchange is observable via `take_alerts()`.
#[tokio::test]
async fn invariant_b1_logout_side_effects_observable() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // Server sends an ALERT, then the mandatory BYE, then tagged OK.
        mock.send(
            format!(
                "* OK [ALERT] important notice\r\n\
                 * BYE see ya\r\n\
                 {tag} OK LOGOUT completed\r\n"
            )
            .as_bytes(),
        )
        .await
        .unwrap();
    });

    let result = conn.logout().await;
    mock_task.await.unwrap();
    assert!(result.is_ok(), "logout should succeed, got: {result:?}");

    // Phase 7b: ALERT during LOGOUT observable via drain_events().
    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts.iter().any(|a| a.contains("important notice")),
        "expected alert text 'important notice', got: {alert_texts:?}"
    );
}

// ---------------------------------------------------------------------------
// B2 — authenticate_plain direct reader strands pre-continuation ALERT
// Fixed in: Phase 1d
// ---------------------------------------------------------------------------

/// Bug: `auth.rs` `authenticate_plain` (non-SASL-IR path) reads exactly one
/// response expecting a `+` continuation. If the server sends an untagged
/// response (e.g., `* OK [ALERT] ...`) before `+`, the code falls through
/// to the catch-all `_ =>` arm and returns `Err(Protocol)`, stranding the
/// ALERT and aborting authentication.
///
/// Correct behavior: the ALERT is buffered, the `+` continuation is processed
/// normally, and authentication succeeds.
#[tokio::test]
async fn invariant_b2_authenticate_plain_alert_observable() {
    // Greeting: AUTH=PLAIN but no SASL-IR, so the non-IR path is taken.
    let (conn, mut mock) =
        mock_connection(b"* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // Server sends an ALERT before the continuation request.
        mock.send(b"* OK [ALERT] attention required\r\n+ \r\n")
            .await
            .unwrap();
        let _payload = mock.receive_line(TIMEOUT).await;
        // Include [CAPABILITY] in the tagged OK so complete_authentication
        // does not issue a follow-up CAPABILITY command (which the mock is
        // not prepared to answer).
        mock.send(
            format!("{tag} OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] authenticated\r\n").as_bytes(),
        )
        .await
        .unwrap();
    });

    let result = conn.authenticate_plain("user", "pass", TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        result.is_ok(),
        "authenticate_plain should succeed despite pre-continuation ALERT, got: {result:?}"
    );

    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts.iter().any(|a| a.contains("attention required")),
        "ALERT before continuation must reach drain_events(), got: {alert_texts:?}"
    );
}

// ---------------------------------------------------------------------------
// B3 — authenticate_xoauth2 same shape as B2
// Fixed in: Phase 1d
// ---------------------------------------------------------------------------

/// Bug: `auth.rs` `authenticate_xoauth2` (non-SASL-IR path) has the same
/// catch-all `_ =>` pattern as `authenticate_plain`. An ALERT before `+`
/// aborts the exchange.
///
/// Correct behavior: same as B2.
#[tokio::test]
async fn invariant_b3_authenticate_xoauth2_alert_observable() {
    let (conn, mut mock) =
        mock_connection(b"* OK [CAPABILITY IMAP4rev1 AUTH=XOAUTH2] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        mock.send(b"* OK [ALERT] token expiring\r\n+ \r\n")
            .await
            .unwrap();
        let _payload = mock.receive_line(TIMEOUT).await;
        // XOAUTH2 uses collect_responses_with_continuation_ack, so the
        // tagged OK is sufficient here. Include [CAPABILITY] so
        // complete_authentication does not issue a follow-up CAPABILITY
        // command.
        mock.send(
            format!("{tag} OK [CAPABILITY IMAP4rev1 AUTH=XOAUTH2] authenticated\r\n").as_bytes(),
        )
        .await
        .unwrap();
    });

    let result = conn.authenticate_xoauth2("user", "token", TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        result.is_ok(),
        "authenticate_xoauth2 should succeed despite pre-continuation ALERT, got: {result:?}"
    );

    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts.iter().any(|a| a.contains("token expiring")),
        "ALERT before continuation must reach drain_events(), got: {alert_texts:?}"
    );
}

// ---------------------------------------------------------------------------
// B4 — append direct reader strands mid-literal ALERT
// Fixed in: Phase 1d
// ---------------------------------------------------------------------------

/// Bug: `append.rs` `append()` reads one response for the literal `+`
/// continuation. If the server sends `* OK [ALERT] ...` before `+`, the
/// catch-all arm returns `Protocol` error, stranding the ALERT.
///
/// Correct behavior: the ALERT is buffered and append succeeds.
#[tokio::test]
async fn invariant_b4_append_midsync_alert_observable() {
    // PREAUTH so we start in Authenticated state (APPEND requires it).
    let (conn, mut mock) = mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // Server sends ALERT before the literal continuation.
        mock.send(b"* OK [ALERT] quota warning\r\n+ go ahead\r\n")
            .await
            .unwrap();
        let _literal = mock.receive(SHORT).await;
        mock.send(format!("{tag} OK APPEND completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let result = conn.append("INBOX", &[], None, b"hello", TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        result.is_ok(),
        "append should succeed despite mid-literal ALERT, got: {result:?}"
    );

    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts.iter().any(|a| a.contains("quota warning")),
        "ALERT during literal sync must reach drain_events(), got: {alert_texts:?}"
    );
}

// ---------------------------------------------------------------------------
// B5 — multi_append same shape as B4
// Fixed in: Phase 1d
// ---------------------------------------------------------------------------

/// Bug: `append.rs` `multi_append()` has the same catch-all pattern for
/// literal `+` as `append()`. An ALERT before `+` aborts the exchange.
///
/// Correct behavior: same as B4.
#[tokio::test]
async fn invariant_b5_multi_append_midsync_alert_observable() {
    let (conn, mut mock) =
        mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1 MULTIAPPEND] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        mock.send(b"* OK [ALERT] storage alert\r\n+ go ahead\r\n")
            .await
            .unwrap();
        let _literal = mock.receive(SHORT).await;
        mock.send(format!("{tag} OK MULTIAPPEND completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let messages = vec![daaki_imap::AppendMessage::new(b"hello".to_vec())];

    let result = conn.multi_append("INBOX", &messages, TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        result.is_ok(),
        "multi_append should succeed despite mid-literal ALERT, got: {result:?}"
    );

    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts.iter().any(|a| a.contains("storage alert")),
        "ALERT during literal sync must reach drain_events(), got: {alert_texts:?}"
    );
}

// ---------------------------------------------------------------------------
// IDLE ALERT delivery — ALERT during IDLE observable via IdleEvent
// (Replaces B6 which tested the now-removed take_alerts() path)
// ---------------------------------------------------------------------------

/// Verify that an `* OK [ALERT] ...` response received during IDLE is
/// delivered as `IdleEvent::Alert(text)` through the typed event queue
/// (RFC 3501 Section 7.1, RFC 2177 Section 3).
///
/// The old B6 test checked `take_alerts()` which no longer exists — alerts
/// are now delivered through the typed event queue and surface as
/// `IdleEvent::Alert` when in IDLE mode.
#[tokio::test]
async fn invariant_idle_alert_delivered() {
    // PREAUTH + IDLE capability so we can enter IDLE without LOGIN.
    let (conn, mut mock) =
        mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1 IDLE] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive the IDLE command.
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        assert!(cmd.contains("IDLE"), "expected IDLE command, got: {cmd}");

        // Send `+` continuation — IDLE is now active.
        mock.send(b"+ idling\r\n").await.unwrap();

        // Server sends an ALERT during IDLE.
        mock.send(b"* OK [ALERT] maintenance tonight\r\n")
            .await
            .unwrap();

        // Wait for the client to send DONE (triggered by seeing the ALERT).
        let done = mock.receive_line(TIMEOUT).await;
        assert!(
            done.contains("DONE"),
            "expected DONE after ALERT, got: {done}"
        );

        // Complete the IDLE with tagged OK.
        mock.send(format!("{tag} OK IDLE completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let cancel = tokio_util::sync::CancellationToken::new();
    let result = conn.idle(TIMEOUT, cancel).await;
    mock_task.await.unwrap();

    assert!(result.is_ok(), "idle should succeed, got: {result:?}");
    let event = result.unwrap();
    match &event {
        daaki_imap::IdleEvent::Alert(text) => {
            assert!(
                text.contains("maintenance tonight"),
                "ALERT text should contain 'maintenance tonight', got: {text:?}"
            );
        }
        other => panic!("ALERT during IDLE must be observable as IdleEvent::Alert, got: {other:?}"),
    }
}

// ---------------------------------------------------------------------------
// B7 — complete_authentication does not apply tagged side effects
// Fixed in: Phase 1d
// ---------------------------------------------------------------------------

/// Bug: `helpers.rs` `complete_authentication()` calls
/// `update_capabilities_from_responses` (which only handles CAPABILITY
/// response codes) but does NOT call `apply_tagged_side_effects` (which
/// handles ALERT, CAPABILITY, and NOTIFICATIONOVERFLOW). When
/// `complete_authentication` is called directly — without going through
/// `collect_responses_until_tag` first — an `[ALERT]` in the tagged
/// response code is lost.
///
/// This path is triggered when `authenticate_plain` (non-SASL-IR) receives
/// a tagged OK before the `+` continuation (server accepts immediately).
///
/// Correct behavior: the tagged ALERT reaches `take_alerts()`.
#[tokio::test]
async fn invariant_b7_authenticate_tagged_alert_observable() {
    // AUTH=PLAIN, no SASL-IR, so the non-IR path is taken.
    let (conn, mut mock) =
        mock_connection(b"* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // Server sends tagged OK with [ALERT] before `+` continuation.
        // This triggers the early-acceptance path in authenticate_plain
        // that calls complete_authentication directly.
        mock.send(format!("{tag} OK [ALERT] password will expire soon\r\n").as_bytes())
            .await
            .unwrap();
        // complete_authentication calls fetch_capabilities() because
        // the tagged response has ALERT code, not CAPABILITY code.
        let cap_cmd = mock.receive_line(TIMEOUT).await;
        let cap_tag = support::extract_tag(&cap_cmd);
        mock.send(
            format!("* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n{cap_tag} OK Capability completed\r\n")
                .as_bytes(),
        )
        .await
        .unwrap();
    });

    let result = conn.authenticate_plain("user", "pass", TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        result.is_ok(),
        "authenticate_plain should succeed, got: {result:?}"
    );

    // Phase 7b: tagged [ALERT] observable via drain_events().
    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts
            .iter()
            .any(|a| a.contains("password will expire soon")),
        "tagged [ALERT] in auth response must reach drain_events(), got: {alert_texts:?}"
    );
}

// ---------------------------------------------------------------------------
// B8 — unexpected continuation is silently dropped
// Fixed in: Phase 1e
// ---------------------------------------------------------------------------

/// Bug: `helpers.rs` `collect_responses_until_tag()` receives an unexpected
/// `+` continuation during a command that does not expect one. It logs a
/// `debug!` message and drops the continuation silently. The command
/// succeeds as if nothing happened.
///
/// Correct behavior: an unexpected continuation during a non-continuation
/// command should return `Err` — the server is either misbehaving or the
/// client/server are desynchronized.
#[tokio::test]
async fn invariant_b8_unexpected_continuation_is_error() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let _cmd = mock.receive_line(TIMEOUT).await;
        // Server sends an unexpected continuation before the tagged response.
        mock.send(b"+ unexpected\r\nA001 OK NOOP completed\r\n")
            .await
            .unwrap();
    });

    // FAILS TODAY — the continuation is silently dropped and NOOP
    // returns Ok. Passes after Phase 1e.
    let result = conn.noop(TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        result.is_err(),
        "unexpected continuation during NOOP must return Err, got: {result:?}"
    );
}

// ---------------------------------------------------------------------------
// B9 — logout sets state to Logout before server confirms
// Fixed in: Phase 1e
// ---------------------------------------------------------------------------

/// Bug: `auth.rs` `logout()` sets `self.state = SessionState::Logout`
/// immediately after sending the LOGOUT command, before reading any server
/// response. If the server disconnects before responding, the I/O error
/// is returned but the state is already `Logout` — hiding the fact that
/// the server never confirmed the transition.
///
/// Correct behavior: session state should only transition to `Logout`
/// after the server confirms with `* BYE` + tagged OK. If the server
/// disconnects before confirming, the state should reflect an error
/// condition (not `Logout`).
#[tokio::test]
async fn invariant_b9_logout_state_after_receive() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let _cmd = mock.receive_line(TIMEOUT).await;
        // Server closes the connection without responding.
        mock.shutdown().await.unwrap();
    });

    let result = conn.logout().await;
    mock_task.await.unwrap();
    // Logout should fail because the server disconnected.
    assert!(result.is_err(), "logout should fail on disconnect");

    // FAILS TODAY — state was set to Logout before reading responses.
    // Passes after Phase 1e.
    assert_ne!(
        conn.session_state(),
        daaki_imap::SessionState::Logout,
        "session state must not be Logout when the server never confirmed \
         the transition (state was set prematurely before server response)"
    );
}

// ---------------------------------------------------------------------------
// B9 corner case — BYE received then disconnect (no tagged OK)
// Fixed in: Phase 1e
// ---------------------------------------------------------------------------

/// Corner case: the server sends `* BYE` but disconnects before sending the
/// tagged OK. The BYE side-effect fires (state → Logout) even though the
/// LOGOUT exchange is incomplete. `logout()` returns an I/O error (no tagged
/// OK), but the session state correctly reflects the BYE that was observed.
///
/// This validates the opposite direction of the main B9 test: B9 checks that
/// a *missing* BYE does NOT set Logout; this checks that a *received* BYE
/// DOES set Logout even when the exchange is cut short.
#[tokio::test]
async fn invariant_b9_logout_bye_then_disconnect() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let _cmd = mock.receive_line(TIMEOUT).await;
        // Server sends BYE, then disconnects without tagged OK.
        mock.send(b"* BYE server shutting down\r\n").await.unwrap();
        mock.shutdown().await.unwrap();
    });

    let result = conn.logout().await;
    mock_task.await.unwrap();
    // Logout fails because no tagged OK was received.
    assert!(result.is_err(), "logout should fail without tagged OK");

    // State IS Logout because BYE was received and apply_side_effects
    // transitioned the state before the I/O error occurred.
    assert_eq!(
        conn.session_state(),
        daaki_imap::SessionState::Logout,
        "session state must be Logout after BYE was received, even without \
         tagged OK (BYE is authoritative per RFC 3501 Section 7.1.5)"
    );
}

// ---------------------------------------------------------------------------
// B9 corner case — tagged OK without mandatory BYE
// Fixed in: Phase 1e
// ---------------------------------------------------------------------------

/// Corner case: the server sends tagged OK for LOGOUT without the mandatory
/// `* BYE` that RFC 3501 Section 6.1.3 requires. `logout()` should return
/// a protocol error about the missing BYE.
#[tokio::test]
async fn invariant_b9_logout_ok_without_bye_is_protocol_error() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // Server sends tagged OK without the mandatory BYE.
        mock.send(format!("{tag} OK LOGOUT completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let result = conn.logout().await;
    mock_task.await.unwrap();
    assert!(
        result.is_err(),
        "logout without mandatory BYE must fail (RFC 3501 Section 6.1.3)"
    );

    let err_msg = format!("{}", result.unwrap_err());
    assert!(
        err_msg.contains("BYE"),
        "error should mention missing BYE, got: {err_msg}"
    );

    // Note: state IS Logout because apply_tagged fires the belt-and-
    // suspenders transition (in_logout + tagged OK) before logout() checks
    // saw_bye. This is Postel's-law correct: the server confirmed the
    // logout, it just violated the wire protocol by omitting BYE. The
    // error return flags the deviation; the state reflects the outcome.
    assert_eq!(
        conn.session_state(),
        daaki_imap::SessionState::Logout,
        "state should be Logout — the server confirmed via tagged OK even \
         though it omitted the mandatory BYE"
    );
}

// ---------------------------------------------------------------------------
// B9 corner case — ALERT during LOGOUT is captured even on failure
// Fixed in: Phase 1e
// ---------------------------------------------------------------------------

/// Corner case: the server sends an ALERT, then BYE, then disconnects
/// (no tagged OK). The ALERT must still be observable via `take_alerts()`
/// even though `logout()` returns an error.
#[tokio::test]
async fn invariant_b9_logout_alert_captured_on_failure() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let _cmd = mock.receive_line(TIMEOUT).await;
        mock.send(b"* OK [ALERT] disk nearly full\r\n* BYE shutting down\r\n")
            .await
            .unwrap();
        mock.shutdown().await.unwrap();
    });

    let result = conn.logout().await;
    mock_task.await.unwrap();
    // Logout fails (no tagged OK).
    assert!(result.is_err(), "logout should fail without tagged OK");

    // Phase 7b: ALERT during failed LOGOUT observable via drain_events().
    let events = conn.drain_events().await;
    let alert_texts: Vec<&str> = events
        .iter()
        .filter_map(|e| match e {
            TypedEvent::Alert(text) => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        alert_texts.iter().any(|a| a.contains("disk nearly full")),
        "ALERT during failed LOGOUT must be in drain_events(), got: {alert_texts:?}"
    );

    // BYE was received, so state should be Logout.
    assert_eq!(
        conn.session_state(),
        daaki_imap::SessionState::Logout,
        "state should be Logout after BYE was received"
    );
}

// ---------------------------------------------------------------------------
// B10 — STARTTLS buffer boundary not checked before upgrade
// Fixed in: Phase 1f
// ---------------------------------------------------------------------------

/// Bug: `lifecycle.rs` STARTTLS checks `read_buf.is_empty()` *after* the
/// TLS handshake, not before. If extra bytes are buffered from the
/// STARTTLS OK response (e.g., injected by a MITM or a buggy server),
/// the code attempts the TLS handshake with those bytes still in
/// `read_buf`. The handshake fails for unrelated reasons (in tests: the
/// mock isn't speaking TLS), masking the real issue.
///
/// Correct behavior: STARTTLS should detect non-empty `read_buf` *before*
/// attempting the TLS handshake and return a `Protocol` error about
/// unexpected bytes at the upgrade boundary.
#[tokio::test]
async fn invariant_b10_starttls_buffer_boundary() {
    // STARTTLS requires NotAuthenticated state, so use a regular OK greeting.
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1 STARTTLS] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // Server sends the OK response followed by extra bytes in the same
        // write. The extra bytes will be buffered in read_buf by
        // read_parsed_response (it reads as much as available from the socket).
        mock.send(format!("{tag} OK STARTTLS completed\r\nINJECTED GARBAGE").as_bytes())
            .await
            .unwrap();
    });

    let result = conn.starttls(TIMEOUT).await;
    mock_task.await.unwrap();

    // Both today and after the fix, STARTTLS should fail.
    assert!(result.is_err(), "starttls with injected bytes must fail");

    // FAILS TODAY — the error is an I/O error from the failed TLS
    // handshake, not a Protocol error about buffer non-empty.
    // Passes after Phase 1f (buffer check moves before TLS handshake).
    let err_msg = format!("{}", result.unwrap_err());
    assert!(
        err_msg.contains("unexpected bytes") || err_msg.contains("buffer"),
        "STARTTLS error should mention buffer/unexpected bytes at upgrade \
         boundary, not a TLS failure. Got: {err_msg}"
    );
}

// ---------------------------------------------------------------------------
// I15 — Encoder/decoder round-trip preserves mailbox names
// Verified by: Phase 2 (MailboxName everywhere)
// ---------------------------------------------------------------------------

/// Invariant I15: the IMAP encoder/decoder round-trip preserves mailbox names
/// including literal `&`, `&-`, CJK characters (`日本語`), and INBOX
/// case-folding.
///
/// The test sends a LIST command for each name — which MUTF-7 encodes the
/// pattern for the wire (RFC 3501 Section 5.1.3) — captures the wire-form
/// bytes from the mock, echoes them back in a `* LIST` response, and
/// verifies the decoded `MailboxName` matches the original.
///
/// This exercises the full encode→wire→decode stack, not just the MUTF-7
/// codec in isolation.
#[tokio::test]
async fn invariant_i15_encode_decode_roundtrip() {
    // Each entry is a decoded (user-facing) mailbox name. The test
    // round-trips it through the encoder and decoder and asserts identity.
    let test_names: &[&str] = &[
        "INBOX",
        "Drafts",
        "Cats & Dogs",
        "日本語",
        "Foo/Bar",
        "Mail/Sent-2024",
        "a",
        "",
        // Literal "&-" as a decoded name — the `&` must survive MUTF-7
        // encoding (`&-` → wire `&--`) and decoding back.
        "&-",
        // A decoded name that *looks like* MUTF-7 — the encoder must
        // escape the leading `&` so the decoder reproduces the original
        // verbatim, not interpret it as a MUTF-7 shift sequence.
        "&BJIEMATwBEIEMAQ1BEE-",
    ];

    for name in test_names {
        roundtrip_one_mailbox_name(name).await;
    }
}

/// Helper: exercise the full encode→wire→decode round-trip for a single
/// mailbox name via the mock connection's LIST command path.
///
/// 1. Client sends `LIST "" "<mutf7_pattern>"`.
/// 2. Mock captures the wire command and extracts the MUTF-7 encoded pattern.
/// 3. Mock echoes it back in `* LIST () "/" "<mutf7_pattern>"`.
/// 4. Client parses the response and produces a `MailboxInfo`.
/// 5. Assert `MailboxInfo.name.as_str() == decoded_name`.
async fn roundtrip_one_mailbox_name(decoded_name: &str) {
    let (conn, mut mock) = mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive the LIST command from the client.
        // Wire format: <tag> LIST "" "<mutf7_pattern>"\r\n
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);

        // Extract the MUTF-7 encoded pattern from the wire command.
        // Splitting by `"` yields:
        //   [0] "<tag> LIST "  [1] ""  [2] " "  [3] "<pattern>"  [4] "\r\n"
        // MUTF-7 output never contains `"`, so this split is safe.
        let parts: Vec<&str> = cmd.split('"').collect();
        assert!(parts.len() >= 5, "unexpected LIST command format: {cmd:?}");
        let wire_pattern = parts[3];

        // Echo the wire-form name back in a LIST response so the decoder
        // can reverse the encoding.
        let response = format!("* LIST () \"/\" \"{wire_pattern}\"\r\n{tag} OK LIST completed\r\n");
        mock.send(response.as_bytes()).await.unwrap();
    });

    let result = conn
        .list("", decoded_name, TIMEOUT)
        .await
        .unwrap_or_else(|e| panic!("list for {decoded_name:?} failed: {e}"));

    mock_task.await.unwrap();

    assert_eq!(
        result.len(),
        1,
        "expected exactly 1 LIST result for {decoded_name:?}, got {}",
        result.len()
    );
    assert_eq!(
        result[0].name.as_str(),
        decoded_name,
        "encode/decode round-trip failed for {decoded_name:?}: \
         encoder produced wire bytes that the decoder did not reverse"
    );
}

// ===========================================================================
// I7 — Cancellation safety (Phase 5l)
//
// The driver-task architecture (Phase 5) makes every public method
// cancellation-safe: the caller-side future holds only a
// `oneshot::Receiver`. Dropping it abandons the result, but the
// driver completes the in-flight command normally and is ready for
// the next.
//
// Each test:
//   1. Issues a command through the mock connection.
//   2. Drops the future via `tokio::time::timeout` before the mock
//      responds.
//   3. Lets the mock send the late response (driver discards the
//      orphaned result).
//   4. Issues a second command and asserts it succeeds.
// ===========================================================================

// ---------------------------------------------------------------------------
// I7 — cancellation safety for CREATE (submit_regular path)
// Verified by: Phase 5l
// ---------------------------------------------------------------------------

/// Invariant I7: dropping a `create()` future mid-flight leaves the
/// connection usable. The driver completes the orphaned command and
/// accepts the next one normally.
#[tokio::test]
#[allow(non_snake_case)]
async fn invariant_I7_cancellation_safety_create() {
    let (conn, mut mock) = mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive CREATE command from client.
        let cmd1 = mock.receive_line(TIMEOUT).await;
        let tag1 = support::extract_tag(&cmd1).to_owned();
        // Delay response so the client-side timeout fires first.
        tokio::time::sleep(Duration::from_millis(300)).await;
        // Late response — driver processes it, result discarded
        // (oneshot receiver already dropped).
        mock.send(format!("{tag1} OK CREATE done\r\n").as_bytes())
            .await
            .unwrap();

        // Receive the follow-up DELETE command.
        let cmd2 = mock.receive_line(TIMEOUT).await;
        let tag2 = support::extract_tag(&cmd2);
        mock.send(format!("{tag2} OK DELETE done\r\n").as_bytes())
            .await
            .unwrap();
    });

    // Start CREATE and cancel after 50 ms.
    let result = tokio::time::timeout(
        Duration::from_millis(50),
        conn.create("CancelTestBox", TIMEOUT),
    )
    .await;
    assert!(result.is_err(), "should have timed out waiting for CREATE");

    // Connection must still be usable — I7.
    // The driver finishes the orphaned CREATE (300 ms mock delay),
    // then picks up DELETE from the channel.
    let delete_result = conn.delete("CancelTestBox", TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        delete_result.is_ok(),
        "connection not usable after cancelling CREATE: {delete_result:?}"
    );
}

// ---------------------------------------------------------------------------
// I7 — cancellation safety for SELECT (complex response path)
// Verified by: Phase 5l
// ---------------------------------------------------------------------------

/// Invariant I7: dropping a `select()` future mid-flight (which
/// accumulates multiple untagged responses) leaves the connection
/// usable for the next command.
#[tokio::test]
#[allow(non_snake_case)]
async fn invariant_I7_cancellation_safety_select() {
    let (conn, mut mock) = mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive SELECT command.
        let cmd1 = mock.receive_line(TIMEOUT).await;
        let tag1 = support::extract_tag(&cmd1).to_owned();
        tokio::time::sleep(Duration::from_millis(300)).await;
        // Full SELECT response with mandatory untagged data
        // (RFC 3501 §6.3.1).
        mock.send(
            format!(
                "* 0 EXISTS\r\n\
                 * 0 RECENT\r\n\
                 * FLAGS (\\Seen \\Answered)\r\n\
                 * OK [PERMANENTFLAGS (\\Seen \\Answered \\*)]\r\n\
                 * OK [UIDVALIDITY 1]\r\n\
                 * OK [UIDNEXT 1]\r\n\
                 {tag1} OK [READ-WRITE] SELECT completed\r\n"
            )
            .as_bytes(),
        )
        .await
        .unwrap();

        // Follow-up: receive CREATE command.
        let cmd2 = mock.receive_line(TIMEOUT).await;
        let tag2 = support::extract_tag(&cmd2);
        mock.send(format!("{tag2} OK CREATE done\r\n").as_bytes())
            .await
            .unwrap();
    });

    // Cancel SELECT after 50 ms.
    let result =
        tokio::time::timeout(Duration::from_millis(50), conn.select("INBOX", TIMEOUT)).await;
    assert!(result.is_err(), "should have timed out waiting for SELECT");

    // Connection must still be usable — I7.
    let create_result = conn.create("PostCancelBox", TIMEOUT).await;
    mock_task.await.unwrap();
    assert!(
        create_result.is_ok(),
        "connection not usable after cancelling SELECT: {create_result:?}"
    );
}

// ---------------------------------------------------------------------------
// I7 — cancellation safety for LOGIN (authentication path)
// Verified by: Phase 5l
// ---------------------------------------------------------------------------

/// Invariant I7: dropping a `login()` future mid-flight leaves the
/// connection usable. The driver completes the orphaned LOGIN (state
/// transitions to Authenticated), and a follow-up `logout()` succeeds.
///
/// `logout()` is used as the follow-up because it is valid in any
/// session state (RFC 3501 §6.1.3), avoiding a timing race between
/// the driver publishing the post-LOGIN state snapshot and the test
/// reading it.
#[tokio::test]
#[allow(non_snake_case)]
async fn invariant_I7_cancellation_safety_login() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive LOGIN command.
        let cmd1 = mock.receive_line(TIMEOUT).await;
        let tag1 = support::extract_tag(&cmd1).to_owned();
        tokio::time::sleep(Duration::from_millis(300)).await;
        // Tagged OK with capabilities so complete_auth skips CAPABILITY.
        mock.send(format!("{tag1} OK [CAPABILITY IMAP4rev1] LOGIN completed\r\n").as_bytes())
            .await
            .unwrap();

        // Follow-up: LOGOUT (valid in any state, avoids state race).
        let cmd2 = mock.receive_line(TIMEOUT).await;
        let tag2 = support::extract_tag(&cmd2);
        mock.send(format!("* BYE see ya\r\n{tag2} OK LOGOUT completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    // Cancel LOGIN after 50 ms.
    let result = tokio::time::timeout(
        Duration::from_millis(50),
        conn.login("user", "pass", TIMEOUT),
    )
    .await;
    assert!(result.is_err(), "should have timed out waiting for LOGIN");

    // Connection must still be usable — I7.
    // The driver finishes the orphaned LOGIN, then picks up LOGOUT.
    let logout_result = conn.logout().await;
    mock_task.await.unwrap();
    assert!(
        logout_result.is_ok(),
        "connection not usable after cancelling LOGIN: {logout_result:?}"
    );
}

// ===========================================================================
// Driver graceful shutdown (Phase 5l)
//
// After logout() succeeds, the driver task exits (Logout state triggers
// a break from the driver loop). Subsequent commands observe DriverGone
// via the closed cmd_rx channel.
// ===========================================================================

// ---------------------------------------------------------------------------
// Driver graceful shutdown — logout then DriverGone
// Verified by: Phase 5l
// ---------------------------------------------------------------------------

/// After `logout()` succeeds, the driver task exits cleanly. Subsequent
/// commands fail with `Error::DriverGone` (not a hang or panic).
#[tokio::test]
async fn invariant_driver_graceful_shutdown() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive LOGOUT command.
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        // RFC 3501 §6.1.3: mandatory BYE followed by tagged OK.
        mock.send(format!("* BYE see ya\r\n{tag} OK LOGOUT completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let logout_result = conn.logout().await;
    mock_task.await.unwrap();
    assert!(
        logout_result.is_ok(),
        "logout should succeed: {logout_result:?}"
    );

    // After logout the driver has exited. Subsequent commands must
    // observe DriverGone without blocking.
    let create_result = tokio::time::timeout(
        Duration::from_millis(500),
        conn.create("ShouldFail", TIMEOUT),
    )
    .await;
    assert!(
        create_result.is_ok(),
        "create after logout must not hang (timed out)"
    );
    let inner = create_result.unwrap();
    // Accept either DriverGone or a Protocol error (state check rejects
    // before reaching the driver). Both are correct behavior.
    assert!(inner.is_err(), "create after logout must fail: {inner:?}");
}

// ===========================================================================
// Pipeline tag-completion barrier — Phase 6c
//
// Verifies that pipelined commands are dispatched correctly: each
// command's untagged responses are routed to the correct consumer,
// and tagged responses finalize the matching consumer.
// ===========================================================================

// ---------------------------------------------------------------------------
// Pipeline — two STATUS commands with correct attribution
// Verified by: Phase 6c
// ---------------------------------------------------------------------------

/// Pipeline of two STATUS commands against a mock server. Verifies that
/// the tag-completion barrier attributes responses to the correct consumer:
/// the first STATUS response goes to the first consumer, the second to the
/// second, even though both are the same command type.
#[tokio::test]
async fn invariant_pipeline_two_status_correct_attribution() {
    let (conn, mut mock) = mock_connection(b"* OK [CAPABILITY IMAP4rev1] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // With sub-batch grouping, two STATUS commands (same CommandKind)
        // are split into separate sub-batches and sent sequentially.
        // Sub-batch 1: first STATUS command.
        let cmd1 = mock.receive_line(TIMEOUT).await;
        assert!(
            cmd1.contains("STATUS"),
            "expected STATUS command, got: {cmd1}"
        );
        let tag1 = cmd1.split_whitespace().next().unwrap();
        let response1 = format!(
            "* STATUS INBOX (MESSAGES 10)\r\n\
             {tag1} OK STATUS done\r\n"
        );
        mock.send(response1.as_bytes()).await.unwrap();

        // Sub-batch 2: second STATUS command.
        let cmd2 = mock.receive_line(TIMEOUT).await;
        assert!(
            cmd2.contains("STATUS"),
            "expected STATUS command, got: {cmd2}"
        );
        let tag2 = cmd2.split_whitespace().next().unwrap();
        assert_ne!(tag1, tag2, "pipeline commands must have distinct tags");
        let response2 = format!(
            "* STATUS Drafts (MESSAGES 5)\r\n\
             {tag2} OK STATUS done\r\n"
        );
        mock.send(response2.as_bytes()).await.unwrap();
    });

    let inbox = daaki_imap::MailboxName::new("INBOX").unwrap();
    let drafts = daaki_imap::MailboxName::new("Drafts").unwrap();

    let (status1_result, status2_result) = conn
        .pipeline()
        .status(inbox, "(MESSAGES)".to_string())
        .status(drafts, "(MESSAGES)".to_string())
        .execute()
        .await
        .unwrap();

    mock_task.await.unwrap();

    // Both commands should succeed.
    let status1 = status1_result.unwrap();
    let status2 = status2_result.unwrap();

    // Assert correct attribution: INBOX got MESSAGES 10, Drafts got MESSAGES 5.
    assert!(!status1.items.is_empty(), "INBOX should have status items");
    assert!(!status2.items.is_empty(), "Drafts should have status items");

    let inbox_messages = status1.items.iter().find_map(|i| match i {
        daaki_imap::StatusItem::Messages(n) => Some(*n),
        _ => None,
    });
    let drafts_messages = status2.items.iter().find_map(|i| match i {
        daaki_imap::StatusItem::Messages(n) => Some(*n),
        _ => None,
    });

    assert_eq!(inbox_messages, Some(10), "INBOX should have 10 messages");
    assert_eq!(drafts_messages, Some(5), "Drafts should have 5 messages");
}

// ===========================================================================
// Phase 7d — NOTIFY/IDLE race regression tests
//
// Regression tests for three historical races fixed by the driver-task
// architecture:
//   - 2ccc44b: EXISTS lost between IDLE command and `+` continuation
//   - 9e3fd3d: events during DONE drain stranded in unsolicited buffer
//   - f1fe78c: LIST classification ambiguity when NOTIFY is active
//
// All three now pass by construction: the driver task owns the stream,
// emits events through DriverEventSink for every untagged response in
// wait_for_continuation, the IDLE reading loop, and drain_idle_responses.
// ===========================================================================

// ---------------------------------------------------------------------------
// Regression for 2ccc44b — EXISTS between IDLE command and `+` continuation
// ---------------------------------------------------------------------------

/// Historical race: the server sends `* 5 EXISTS` between the IDLE command
/// and the `+` continuation. In the old architecture, `read_idle_event()`
/// only read from the socket after `+`, so the EXISTS was saved to the
/// unsolicited buffer and never checked — IDLE blocked for up to 29 minutes.
///
/// Fixed by construction: the driver task's `wait_for_continuation` loop
/// (Phase 5) emits every untagged response it encounters to the event sink
/// before returning on `+`. The handle-side `idle()` picks up the event
/// via `next_event`, sends DONE, and returns it immediately.
#[tokio::test]
async fn invariant_idle_events_buffered_pre_continuation() {
    // PREAUTH + IDLE capability so we can enter IDLE without LOGIN.
    let (conn, mut mock) =
        mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1 IDLE] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive the IDLE command.
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        assert!(cmd.contains("IDLE"), "expected IDLE command, got: {cmd}");

        // Server sends an EXISTS *before* the `+` continuation.
        // This is the race window from 2ccc44b.
        mock.send(b"* 5 EXISTS\r\n").await.unwrap();
        mock.send(b"+ idling\r\n").await.unwrap();

        // The client should observe the EXISTS event and send DONE.
        let done = mock.receive_line(TIMEOUT).await;
        assert!(
            done.contains("DONE"),
            "expected DONE after pre-continuation EXISTS, got: {done}"
        );

        // Complete the IDLE with tagged OK.
        mock.send(format!("{tag} OK IDLE completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let cancel = tokio_util::sync::CancellationToken::new();
    let result = conn.idle(TIMEOUT, cancel).await;
    mock_task.await.unwrap();

    assert!(result.is_ok(), "idle should succeed, got: {result:?}");
    let event = result.unwrap();
    assert!(
        matches!(event, daaki_imap::IdleEvent::Exists(5)),
        "pre-continuation EXISTS must be observable as IdleEvent::Exists(5), got: {event:?}"
    );
}

// ---------------------------------------------------------------------------
// Regression for 9e3fd3d — events during DONE drain stranded
// ---------------------------------------------------------------------------

/// Historical race: when IDLE times out and `send_done_and_drain` runs,
/// untagged responses (EXISTS, EXPUNGE, etc.) arriving between DONE and
/// the tagged OK were saved to `self.unsolicited` but never checked by
/// the next `idle()` call. The caller saw repeated Timeouts while messages
/// had actually arrived.
///
/// Fixed by construction: the driver task's `drain_idle_responses` loop
/// emits every untagged response to the event sink (Phase 5). Events
/// between DONE and tagged OK are observable via `drain_events()` or
/// `next_event()` after `idle()` returns.
#[tokio::test]
async fn invariant_idle_events_during_done_drain() {
    let (conn, mut mock) =
        mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1 IDLE] ready\r\n").await;

    let mock_task = tokio::spawn(async move {
        // Receive the IDLE command.
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        assert!(cmd.contains("IDLE"), "expected IDLE command, got: {cmd}");

        // Send `+` continuation — IDLE is now active.
        mock.send(b"+ idling\r\n").await.unwrap();

        // Wait for the client to send DONE (triggered by short timeout).
        let done = mock.receive_line(TIMEOUT).await;
        assert!(done.contains("DONE"), "expected DONE, got: {done}");

        // After DONE, before tagged OK: send events in the drain window.
        // This is the race window from 9e3fd3d.
        mock.send(b"* 3 EXISTS\r\n").await.unwrap();
        mock.send(b"* 7 EXPUNGE\r\n").await.unwrap();
        mock.send(format!("{tag} OK IDLE completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    // Use a short timeout so IDLE exits quickly via timeout, triggering DONE.
    let cancel = tokio_util::sync::CancellationToken::new();
    let result = conn.idle(SHORT, cancel).await;
    mock_task.await.unwrap();

    assert!(result.is_ok(), "idle should succeed, got: {result:?}");
    let event = result.unwrap();
    assert!(
        matches!(event, daaki_imap::IdleEvent::Timeout),
        "idle should return Timeout, got: {event:?}"
    );

    // The events sent during the DONE drain window must be observable.
    // Give the driver a moment to process the drain responses.
    let events = conn.drain_events().await;
    let has_exists = events.iter().any(|e| matches!(e, TypedEvent::Exists(3)));
    let has_expunge = events.iter().any(|e| matches!(e, TypedEvent::Expunge(7)));
    assert!(
        has_exists,
        "EXISTS during DONE drain must be observable via drain_events(), got: {events:?}"
    );
    assert!(
        has_expunge,
        "EXPUNGE during DONE drain must be observable via drain_events(), got: {events:?}"
    );
}

// ---------------------------------------------------------------------------
// Regression for f1fe78c — NOTIFY markerless LIST classification
// ---------------------------------------------------------------------------

/// Historical race: when NOTIFY is active and the server delivers a
/// `* LIST` response without `\NonExistent` (i.e., a normal mailbox event
/// such as `MailboxName` or `SubscriptionChange` per RFC 5465 §5.4–5.5), the
/// old architecture's predicate-based dispatch could misroute or drop it.
///
/// Fixed by construction: the driver task's IDLE reading loop emits every
/// untagged response to the event sink. `UntaggedResponse::List` converts
/// to `TypedEvent::MailboxEvent` via `From<UntaggedResponse>`. Outside of
/// a LIST/LIST-STATUS command, `classify` returns `OnlyUnsolicited` when
/// NOTIFY list events are active (classification.rs line 133), so the
/// dispatcher routes it to the event sink. During SELECT/EXAMINE,
/// `classify` returns `Either` (classification.rs line 115) and the
/// consumer's finalize handles the ambiguity.
///
/// This test verifies the IDLE path: with NOTIFY active, a `* LIST`
/// without `\NonExistent` during IDLE is correctly emitted as a
/// `TypedEvent::MailboxEvent` and observable as the `idle()` return value.
#[tokio::test]
async fn invariant_notify_markerless_list_classification() {
    // PREAUTH + IDLE + NOTIFY capabilities.
    let (conn, mut mock) =
        mock_connection(b"* PREAUTH [CAPABILITY IMAP4rev1 IDLE NOTIFY] ready\r\n").await;

    // Enable NOTIFY with MailboxName events on personal mailboxes.
    let mock_notify = tokio::spawn(async move {
        // Receive the NOTIFY SET command.
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        assert!(
            cmd.contains("NOTIFY"),
            "expected NOTIFY SET command, got: {cmd}"
        );

        // Tagged OK — NOTIFY is now active.
        mock.send(format!("{tag} OK NOTIFY SET completed\r\n").as_bytes())
            .await
            .unwrap();
        mock
    });

    let params = daaki_imap::NotifySetParams::new(
        vec![daaki_imap::NotifyEventGroup::new(
            daaki_imap::MailboxFilter::Personal,
            vec![daaki_imap::NotifyEvent::MailboxName],
        )],
        false,
    );
    conn.notify_set(params, TIMEOUT).await.unwrap();
    let mut mock = mock_notify.await.unwrap();

    // Now enter IDLE. During IDLE, the server sends a `* LIST` without
    // `\NonExistent` — a normal MailboxName NOTIFY event.
    let mock_idle = tokio::spawn(async move {
        let cmd = mock.receive_line(TIMEOUT).await;
        let tag = support::extract_tag(&cmd);
        assert!(cmd.contains("IDLE"), "expected IDLE command, got: {cmd}");
        mock.send(b"+ idling\r\n").await.unwrap();

        // Server sends a LIST response — a NOTIFY MailboxName event
        // (RFC 5465 §5.4). No `\NonExistent` marker — this is a
        // normal mailbox, not a deletion.
        mock.send(b"* LIST (\\Subscribed) \"/\" \"SharedFolder\"\r\n")
            .await
            .unwrap();

        // Client should observe the event and send DONE.
        let done = mock.receive_line(TIMEOUT).await;
        assert!(
            done.contains("DONE"),
            "expected DONE after LIST event, got: {done}"
        );

        mock.send(format!("{tag} OK IDLE completed\r\n").as_bytes())
            .await
            .unwrap();
    });

    let cancel = tokio_util::sync::CancellationToken::new();
    let result = conn.idle(TIMEOUT, cancel).await;
    mock_idle.await.unwrap();

    assert!(result.is_ok(), "idle should succeed, got: {result:?}");
    let event = result.unwrap();

    // The LIST response should be converted to MailboxEvent.
    match &event {
        daaki_imap::IdleEvent::MailboxEvent(info) => {
            assert_eq!(
                info.name.as_ref(),
                "SharedFolder",
                "MailboxEvent should carry the mailbox name"
            );
        }
        other => panic!(
            "NOTIFY LIST without \\NonExistent must be observable as \
             IdleEvent::MailboxEvent, got: {other:?}"
        ),
    }
}
