//! MC-13 — Slack pairing is code-proven (same bar as iMessage).
//!
//! (1) Mint a Slack code (host-gated) → member DMs the EXACT code → that member
//!     is bound into the Slack allowlist.
//! (2) Member DMs a WRONG code → allowlist UNCHANGED (nothing bound).
//! (3) An inbound event with a bare member id and NO code → nothing bound.
//! (4) Single-use: DM the already-consumed code → nothing bound.
//!
//! The ONLY allowlist-bind edge reachable from a Slack inbound event runs
//! through `validate_and_consume_pairing_code_for` — there is no free-type
//! member-id setter reachable from inbound. A wrong code (constant-time
//! mismatch) binds nothing; a bare member id binds nothing.

mod slack_mock;
use slack_mock::MockSlackTransport;

use car_server_types::host::HostState;
use car_messaging::messaging_config::{ChannelId, MessagingConfigStore};
use car_messaging::slack_adapter::{SlackAdapter, SlackInboundEvent};
use car_messaging::slack_adapter::SlackTransport; // for `next_event` on the mock
use std::sync::Arc;
use tempfile::TempDir;

const SLACK_MEMBER: &str = "U012ABCDEF";
const SLACK_CHANNEL: &str = "D024BE91L";

/// Build an enabled-Slack harness with an EMPTY Slack allowlist (no member
/// paired yet — pairing is what binds the first one).
struct PairingHarness {
    store: MessagingConfigStore,
    transport: Arc<MockSlackTransport>,
    adapter: SlackAdapter,
    _dir: TempDir,
}

impl PairingHarness {
    fn enabled_unpaired() -> Self {
        let dir = TempDir::new().expect("tempdir");
        let store = MessagingConfigStore::with_base_dir(dir.path());
        store.set_enabled_for(ChannelId::Slack, true).unwrap();
        // No handle paired — the Slack allowlist is empty.
        let host = Arc::new(HostState::new());
        let transport = MockSlackTransport::new();
        let adapter = SlackAdapter::new(
            host,
            MessagingConfigStore::with_base_dir(dir.path()),
            transport.clone(),
            SLACK_CHANNEL,
        );
        Self {
            store,
            transport,
            adapter,
            _dir: dir,
        }
    }

    fn slack_allowlist(&self) -> Vec<String> {
        self.store.allowlist_for(ChannelId::Slack).unwrap()
    }

    /// Pump one injected event through the adapter.
    async fn pump(&self) {
        let ev = self.transport.next_event().await.expect("an injected event");
        self.adapter.handle_event(&ev).await;
    }
}

#[tokio::test]
async fn slack_pairing_exact_code_binds_member() {
    let h = PairingHarness::enabled_unpaired();
    assert!(h.slack_allowlist().is_empty(), "starts empty");

    // Host-gated: mint a Slack pairing code (shown only in local UI).
    let code = h.store.mint_pairing_code_for(ChannelId::Slack).unwrap();

    // Member DMs the EXACT code → bound into the Slack allowlist.
    h.transport.inject_pairing_dm(SLACK_MEMBER, &code);
    h.pump().await;

    assert_eq!(
        h.slack_allowlist(),
        vec![SLACK_MEMBER.to_string()],
        "the exact code binds exactly the sending member"
    );
    // The code is single-use — consumed on the bind.
    assert!(
        h.store
            .active_pairing_code_for(ChannelId::Slack)
            .unwrap()
            .is_none(),
        "the code is cleared after a successful bind"
    );
}

#[tokio::test]
async fn slack_pairing_wrong_code_binds_nothing() {
    let h = PairingHarness::enabled_unpaired();
    let code = h.store.mint_pairing_code_for(ChannelId::Slack).unwrap();

    // A one-character-off code (same length → exercises the constant-time miss).
    let mut wrong = code.clone();
    let last = wrong.pop().unwrap();
    wrong.push(if last == 'A' { 'B' } else { 'A' });
    assert_eq!(wrong.len(), code.len(), "equal-length wrong code");

    h.transport.inject_pairing_dm(SLACK_MEMBER, &wrong);
    h.pump().await;

    assert!(
        h.slack_allowlist().is_empty(),
        "a wrong code must bind nothing"
    );
    assert!(
        h.store
            .active_pairing_code_for(ChannelId::Slack)
            .unwrap()
            .is_some(),
        "a wrong code must not consume the active code"
    );
}

#[tokio::test]
async fn slack_bare_member_id_no_code_binds_nothing() {
    let h = PairingHarness::enabled_unpaired();
    // A code IS in flight, but the inbound carries NO code (empty text) — a
    // bare member id. Nothing binds (no free-type member-id trust path).
    let _code = h.store.mint_pairing_code_for(ChannelId::Slack).unwrap();

    // Empty-text DM (a bare member-id-shaped event).
    h.transport
        .inject(SlackInboundEvent::PairingDm {
            user: SLACK_MEMBER.to_string(),
            text: String::new(),
        });
    h.pump().await;
    assert!(
        h.slack_allowlist().is_empty(),
        "a bare member id with no code must bind nothing"
    );

    // A button interaction (which carries a member id but is NOT a pairing
    // shape) also never binds — it routes to resolve-by-id, not to a setter.
    h.transport.inject_approve("some-approval-id", SLACK_MEMBER);
    h.pump().await;
    assert!(
        h.slack_allowlist().is_empty(),
        "a button interaction never binds a member id (no inbound→setter edge)"
    );
}

#[tokio::test]
async fn slack_consumed_code_cannot_bind_again() {
    let h = PairingHarness::enabled_unpaired();
    let code = h.store.mint_pairing_code_for(ChannelId::Slack).unwrap();

    // First member binds with the exact code.
    h.transport.inject_pairing_dm(SLACK_MEMBER, &code);
    h.pump().await;
    assert_eq!(h.slack_allowlist(), vec![SLACK_MEMBER.to_string()]);

    // A DIFFERENT member re-echoes the now-consumed code → nothing further
    // binds (single-use code + v1 single-handle invariant).
    h.transport.inject_pairing_dm("U999ZZZZZZ", &code);
    h.pump().await;
    assert_eq!(
        h.slack_allowlist(),
        vec![SLACK_MEMBER.to_string()],
        "the consumed code cannot bind a second member"
    );
}
