//! MC-9 + MC-6 over the REAL `messaging.config.set` WebSocket provisioning path.
//!
//! The Slack UI (`MessagingSettingsView.swift::saveSlackTokens`) drives a
//! host-gated `messaging.config.set { channel: "slack", bot_token, app_token }`.
//! This test proves the END-TO-END wire path the function-level MC-9 unit test
//! never exercised:
//!
//! 1. **MC-9 (no plaintext token):** a host-authorized provisioning call writes
//!    the bearer tokens to the OS keychain via `SecretStore::put` (driven by
//!    `provision_slack_tokens`) and persists ONLY a keychain *reference* into
//!    `messaging.json`. We assert the on-disk config carries the `slack_token_ref`
//!    (the provisioned marker) and that `messaging.json` contains NO `xoxb`/
//!    `xapp` bearer value — only the ref key names.
//! 2. **MC-6 (provisioning host-gated, never inbound):** the SAME provisioning
//!    call from a NON-host connection is REJECTED by `require_approval_authority`
//!    — provisioning is host-only; an inbound message carries no WS session and
//!    so can never reach this setter.
//!
//! The provisioning write goes through `SecretStore`. To exercise the real
//! `put`/`get` code path WITHOUT the macOS keychain's interactive-authorization
//! prompt (which cancels unattended, `code=154`), the test points
//! `CAR_SECRETS_FILE_DIR` at a temp dir — the same env-keyed redirect idiom the
//! crate already uses for env-first key resolution. Production never sets this
//! var, so the daemon path is unchanged (real keychain). The bearer values it
//! writes are the harmless `xoxb-test`/`xapp-test` literals.

use car_memgine::MemgineEngine;
use car_secrets::{SecretRef, SecretStore, DEFAULT_SERVICE};
use car_server_core::slack_adapter::{SLACK_APP_TOKEN_KEY, SLACK_BOT_TOKEN_KEY};
use car_server_core::{run_dispatch, ServerState, ServerStateConfig};
use futures::{SinkExt, StreamExt};
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::{accept_async, connect_async, tungstenite::Message};

type Ws =
    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;

const HOST_TOKEN: &str = "test-host-token-bbbbbbbbbbbbbbbbbbbbbbbbbbb";
const BOT_BEARER: &str = "xoxb-test-bot-token-value";
const APP_BEARER: &str = "xapp-test-app-token-value";

fn gated_state(journal_dir: std::path::PathBuf) -> Arc<ServerState> {
    let engine = Arc::new(Mutex::new(MemgineEngine::new(None)));
    let cfg = ServerStateConfig::new(journal_dir).with_shared_memgine(engine);
    let state = Arc::new(ServerState::with_config(cfg));
    state
        .install_host_token(HOST_TOKEN.to_string())
        .expect("install host token");
    state
}

async fn spawn_dispatcher(state: Arc<ServerState>) -> SocketAddr {
    let listener = TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(
        Ipv4Addr::new(127, 0, 0, 1),
        0,
    )))
    .await
    .expect("bind loopback");
    let addr = listener.local_addr().expect("local_addr");
    tokio::spawn(async move {
        let (stream, peer) = listener.accept().await.expect("accept");
        let ws = accept_async(stream).await.expect("ws handshake");
        let (write, read) = ws.split();
        let _ = run_dispatch(read, Box::pin(write), peer.to_string(), state).await;
    });
    addr
}

async fn call(
    ws: &mut Ws,
    id: &str,
    method: &str,
    params: serde_json::Value,
) -> serde_json::Value {
    ws.send(Message::Text(
        serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
            .to_string()
            .into(),
    ))
    .await
    .expect("send");
    let text = ws
        .next()
        .await
        .expect("frame")
        .expect("frame ok")
        .into_text()
        .expect("text")
        .to_string();
    serde_json::from_str(&text).expect("parse")
}

/// Delete the two well-known Slack secret entries this test may have written.
///
/// While `CAR_SECRETS_FILE_DIR` is set (it is, for this test), `SecretStore`
/// routes through the FILE BACKEND, not the OS keychain — so these deletes hit
/// the temp file dir, leaving the developer's real keychain untouched
/// regardless. The name is kept for continuity; the operation is a clean-slot
/// reset on whatever backend is active. Idempotent.
fn cleanup_keychain() {
    let store = SecretStore::new();
    let _ = store.delete(&SecretRef::new(DEFAULT_SERVICE, SLACK_BOT_TOKEN_KEY));
    let _ = store.delete(&SecretRef::new(DEFAULT_SERVICE, SLACK_APP_TOKEN_KEY));
}

/// Serializes the process-global `HOME` / `CAR_SECRETS_FILE_DIR` env mutation
/// below. There is one test in this binary today, but env vars are
/// process-global: if a second `#[tokio::test]` is ever added here it would
/// race this one's `set_var`. Holding this lock across the whole test pins the
/// one-mutator-at-a-time invariant so the file backend never points at the
/// wrong temp dir mid-run.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// MC-9 + MC-6 over the real WS provisioning path.
#[tokio::test]
async fn slack_token_provisioning_ws_path() {
    // Hold the process-global env mutation lock for the whole test (S5): `HOME`
    // / `CAR_SECRETS_FILE_DIR` are process-wide; a future second test in this
    // binary must not race the `set_var`s below. `.unwrap_or_else(|e| e.into_inner())`
    // keeps the test running even if a prior test panicked while holding it.
    let _env_guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
    let journal = TempDir::new().unwrap();
    // Redirect `~/.car/` to a temp HOME so the handlers don't touch the
    // developer's real config.
    let fake_home = TempDir::new().unwrap();
    std::env::set_var("HOME", fake_home.path());
    // Redirect the secret store to a temp file backend so the provisioning
    // write exercises the real SecretStore::put path with NO keychain prompt.
    // Production never sets this var (daemon uses the real keychain).
    let secrets_dir = TempDir::new().unwrap();
    std::env::set_var("CAR_SECRETS_FILE_DIR", secrets_dir.path());
    assert!(
        SecretStore::new().is_available(),
        "file-backed secret store must report available"
    );
    cleanup_keychain(); // start from a known-clean secret slot

    let messaging_json = fake_home.path().join(".car").join("messaging.json");

    let state = gated_state(journal.path().to_path_buf());
    let addr = spawn_dispatcher(state).await;
    let (mut ws, _) = connect_async(format!("ws://{addr}"))
        .await
        .expect("connect");

    // ---- MC-6: provisioning WITHOUT host authority is REJECTED ----
    // The same provisioning call from a non-host connection must be rejected by
    // require_approval_authority BEFORE any keychain write — provisioning is
    // host-gated, never inbound.
    let rejected = call(
        &mut ws,
        "n1",
        "messaging.config.set",
        serde_json::json!({
            "channel": "slack",
            "bot_token": BOT_BEARER,
            "app_token": APP_BEARER,
        }),
    )
    .await;
    assert!(
        rejected.get("error").is_some(),
        "provisioning must REJECT a non-host caller (MC-6), got: {rejected}"
    );
    assert!(
        rejected.get("result").is_none(),
        "provisioning must not return a result to a non-host caller"
    );
    // And it must NOT have written either keychain entry or a config ref.
    assert!(
        SecretStore::new()
            .get(&SecretRef::new(DEFAULT_SERVICE, SLACK_BOT_TOKEN_KEY))
            .is_err(),
        "a rejected (non-host) provisioning attempt must not write the bot token"
    );
    assert!(
        !messaging_json.exists()
            || !std::fs::read_to_string(&messaging_json)
                .unwrap()
                .contains("slack_token_ref"),
        "a rejected provisioning attempt must not persist a token ref"
    );

    // ---- Authenticate as host ----
    let auth = call(
        &mut ws,
        "auth",
        "session.auth",
        serde_json::json!({ "host_token": HOST_TOKEN }),
    )
    .await;
    assert_eq!(auth["result"]["role"], "host", "host auth should succeed: {auth}");

    // ---- MC-9: host-authorized provisioning writes keychain + ref ----
    let set = call(
        &mut ws,
        "h1",
        "messaging.config.set",
        serde_json::json!({
            "channel": "slack",
            "bot_token": BOT_BEARER,
            "app_token": APP_BEARER,
        }),
    )
    .await;
    assert!(
        set.get("error").is_none(),
        "host slack provisioning should succeed: {set}"
    );
    // The view never echoes a bearer value.
    let view_text = set.to_string();
    assert!(
        !view_text.contains("xoxb") && !view_text.contains("xapp"),
        "the config.set response must NOT echo any bearer token: {view_text}"
    );

    // (a) SecretStore::put was the write path: the bearer tokens are now
    // readable from the keychain under the well-known ref keys.
    let store = SecretStore::new();
    assert_eq!(
        store
            .get(&SecretRef::new(DEFAULT_SERVICE, SLACK_BOT_TOKEN_KEY))
            .expect("bot token must be in keychain after provisioning"),
        BOT_BEARER,
        "provision_slack_tokens must SecretStore::put the bot bearer"
    );
    assert_eq!(
        store
            .get(&SecretRef::new(DEFAULT_SERVICE, SLACK_APP_TOKEN_KEY))
            .expect("app token must be in keychain after provisioning"),
        APP_BEARER,
        "provision_slack_tokens must SecretStore::put the app bearer"
    );

    // (b) messaging.json holds the REF (provisioned marker) but NO bearer.
    let on_disk = std::fs::read_to_string(&messaging_json)
        .expect("messaging.json must exist after provisioning");
    assert!(
        on_disk.contains("slack_token_ref"),
        "messaging.json must carry the provisioned token ref marker: {on_disk}"
    );
    assert!(
        on_disk.contains(SLACK_BOT_TOKEN_KEY) && on_disk.contains(SLACK_APP_TOKEN_KEY),
        "messaging.json must carry the keychain REF key names: {on_disk}"
    );
    assert!(
        !on_disk.contains("xoxb") && !on_disk.contains("xapp"),
        "MC-9 VIOLATION: messaging.json must contain NO bearer value, only the ref: {on_disk}"
    );

    cleanup_keychain();
}
