use super::*;
use crate::openhuman::credentials::session_support::local_session_user_id;
use axum::http::StatusCode;
use axum::routing::get;
use axum::Router;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;

struct EnvVarGuard {
    key: &'static str,
    previous: Option<std::ffi::OsString>,
}

impl EnvVarGuard {
    fn set_to_path(key: &'static str, path: &std::path::Path) -> Self {
        let previous = std::env::var_os(key);
        unsafe { std::env::set_var(key, path) };
        Self { key, previous }
    }
}

impl Drop for EnvVarGuard {
    fn drop(&mut self) {
        unsafe {
            match self.previous.take() {
                Some(value) => std::env::set_var(self.key, value),
                None => std::env::remove_var(self.key),
            }
        }
    }
}

fn test_config(tmp: &TempDir) -> Config {
    Config {
        workspace_dir: tmp.path().join("workspace"),
        action_dir: tmp.path().join("workspace"),
        config_path: tmp.path().join("config.toml"),
        ..Config::default()
    }
}

fn jwt_with_payload(payload: serde_json::Value) -> String {
    let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
    format!("eyJhbGciOiJIUzI1NiJ9.{payload}.sig")
}

async fn spawn_auth_me_status(status: StatusCode) -> String {
    let app = Router::new().route("/auth/me", get(move || async move { status }));
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, app).await.unwrap();
    });
    format!("http://{addr}")
}

// ── secret_store_for_config ────────────────────────────────────

#[test]
fn secret_store_for_config_scopes_to_config_parent() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    // Build the store — must not panic and must operate under tmp path.
    let _store = secret_store_for_config(&config);
}

// ── encrypt_secret / decrypt_secret ───────────────────────────

#[tokio::test]
async fn encrypt_then_decrypt_round_trips_locally() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let plaintext = "top-secret-value";
    let enc = encrypt_secret(&config, plaintext).await.unwrap();
    assert_ne!(enc.value, plaintext);
    let dec = decrypt_secret(&config, &enc.value).await.unwrap();
    assert_eq!(dec.value, plaintext);
}

#[tokio::test]
async fn decrypt_secret_round_trips_noise_through_migrate_path() {
    // `decrypt` accepts legacy plaintext values (migration path) rather
    // than erroring — validate that behaviour by round-tripping a
    // non-ciphertext input. The assertion only checks that we get a
    // deterministic `Ok`, not what the value is.
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let res = decrypt_secret(&config, "not-a-real-ciphertext").await;
    assert!(
        res.is_ok(),
        "decrypt should accept non-ciphertext via migrate path, got {res:?}"
    );
}

// ── store_session (input validation) ──────────────────────────

#[tokio::test]
async fn store_session_rejects_empty_or_whitespace_token() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = store_session(&config, "", None, None).await.unwrap_err();
    assert!(err.contains("token is required"));
    let err = store_session(&config, "   ", None, None).await.unwrap_err();
    assert!(err.contains("token is required"));
}

#[test]
fn sanitize_stored_session_user_discards_empty_objects() {
    assert_eq!(sanitize_stored_session_user(Some(json!({}))), None);
    assert_eq!(
        sanitize_stored_session_user(Some(serde_json::Value::Null)),
        None
    );
    assert_eq!(
        sanitize_stored_session_user(Some(json!({ "firstName": "steven" }))),
        Some(json!({ "firstName": "steven" }))
    );
}

#[test]
fn auth_me_store_failure_classifier_only_accepts_transient_shapes() {
    assert!(auth_me_store_failure_is_transient(
        "GET /auth/me failed (503 Service Unavailable): overloaded"
    ));
    assert!(auth_me_store_failure_is_transient(
        "GET /auth/me failed (503 Service Unavailable): session timeout"
    ));
    assert!(auth_me_store_failure_is_transient(
        "GET /auth/me: error sending request for url"
    ));
    assert!(!auth_me_store_failure_is_transient(
        "GET /auth/me failed (401 Unauthorized): bad token"
    ));
    assert!(!auth_me_store_failure_is_transient(
        "GET /auth/me failed (401 Unauthorized): session timeout"
    ));
    assert!(!auth_me_store_failure_is_transient(
        "GET /auth/me failed (403 Forbidden): connection reset"
    ));
}

#[tokio::test]
async fn store_session_rejects_live_jwt_when_auth_me_transient() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let mut config = test_config(&tmp);
    config.api_url = Some(spawn_auth_me_status(StatusCode::SERVICE_UNAVAILABLE).await);
    let token = jwt_with_payload(json!({
        "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
    }));

    let err = store_session(&config, &token, None, Some(json!({})))
        .await
        .unwrap_err();

    assert!(
        err.contains("Session validation failed (GET /auth/me)"),
        "expected auth/me validation error, got: {err}"
    );
    let state = auth_get_state(&config).await.unwrap().value;
    assert!(!state.is_authenticated);
    assert!(state.user.is_none());
}

#[tokio::test]
async fn store_session_rejects_supplied_user_when_auth_me_transient() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let mut config = test_config(&tmp);
    config.api_url = Some(spawn_auth_me_status(StatusCode::SERVICE_UNAVAILABLE).await);
    let token = jwt_with_payload(json!({
        "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
    }));

    let err = store_session(
        &config,
        &token,
        None,
        Some(json!({
            "name": "Callback User",
            "email": "callback@example.test"
        })),
    )
    .await
    .unwrap_err();

    assert!(
        err.contains("Session validation failed (GET /auth/me)"),
        "expected auth/me validation error, got: {err}"
    );
    let state = auth_get_state(&config).await.unwrap().value;
    assert!(!state.is_authenticated);
    assert!(state.user.is_none());
}

#[tokio::test]
async fn store_session_rejects_non_object_user_when_auth_me_transient() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let mut config = test_config(&tmp);
    config.api_url = Some(spawn_auth_me_status(StatusCode::SERVICE_UNAVAILABLE).await);
    let token = jwt_with_payload(json!({
        "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
    }));

    let err = store_session(&config, &token, None, Some(json!("callback-user")))
        .await
        .unwrap_err();

    assert!(
        err.contains("Session validation failed (GET /auth/me)"),
        "expected auth/me validation error, got: {err}"
    );
    let state = auth_get_state(&config).await.unwrap().value;
    assert!(!state.is_authenticated);
    assert!(state.user.is_none());
}

#[tokio::test]
async fn store_session_defers_minimal_live_jwt_when_auth_me_transient_and_allowed() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let mut config = test_config(&tmp);
    config.api_url = Some(spawn_auth_me_status(StatusCode::SERVICE_UNAVAILABLE).await);
    let token = jwt_with_payload(json!({
        "sub": "unverified-jwt-user",
        "email": "jwt@example.test",
        "name": "Unverified JWT User",
        "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
    }));

    let result = store_session_with_deferred_validation(
        &config,
        &token,
        None,
        Some(json!({
            "id": "supplied-callback-user",
            "name": "Supplied Callback User"
        })),
    )
    .await
    .unwrap();

    assert!(result.value.has_token);
    let log_text = result.logs.join(" ");
    assert!(
        log_text.contains("session JWT accepted with deferred GET /auth/me validation"),
        "expected deferred validation log, got: {log_text}"
    );
    let state = auth_get_state(&config).await.unwrap().value;
    assert!(state.is_authenticated);
    assert_eq!(
        state.user,
        Some(json!({ "pendingBackendValidation": true })),
        "deferred fallback must not copy identity claims from an unverified JWT or callback payload"
    );
}

#[tokio::test]
async fn deferred_session_without_user_id_does_not_replace_active_user_profile() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let root_dir = default_root_openhuman_dir().unwrap();
    let active_user_id = "existing-active-user";
    write_active_user_id(&root_dir, active_user_id).unwrap();
    let active_user_dir = user_openhuman_dir(&root_dir, active_user_id);
    std::fs::create_dir_all(active_user_dir.join("workspace")).unwrap();
    let mut config = Config {
        config_path: active_user_dir.join("config.toml"),
        workspace_dir: active_user_dir.join("workspace"),
        action_dir: active_user_dir.join("workspace"),
        ..Config::default()
    };
    config.api_url = Some(spawn_auth_me_status(StatusCode::SERVICE_UNAVAILABLE).await);
    let mut metadata = std::collections::HashMap::new();
    metadata.insert("user_id".to_string(), active_user_id.to_string());
    metadata.insert(
        "user_json".to_string(),
        json!({
            "id": active_user_id,
            "name": "Existing Active User"
        })
        .to_string(),
    );
    AuthService::from_config(&config)
        .store_provider_token(
            APP_SESSION_PROVIDER,
            DEFAULT_AUTH_PROFILE_NAME,
            "existing.active.session",
            metadata,
            true,
        )
        .unwrap();
    let pending_token = jwt_with_payload(json!({
        "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
    }));

    let err =
        store_session_with_deferred_validation(&config, &pending_token, None, Some(json!({})))
            .await
            .unwrap_err();

    assert!(
        err.contains("backend user id required before replacing the active session"),
        "expected active-session protection error, got: {err}"
    );
    let state = auth_get_state(&config).await.unwrap().value;
    assert!(state.is_authenticated);
    let token = auth_get_session_token_json(&config).await.unwrap().value;
    assert_eq!(token.get("token"), Some(&json!("existing.active.session")));
    assert_eq!(state.user_id.as_deref(), Some(active_user_id));
    assert_eq!(
        state.user.as_ref().and_then(|value| value.get("id")),
        Some(&json!(active_user_id))
    );
}

#[tokio::test]
async fn store_session_rejects_live_jwt_when_auth_me_unauthorized() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let mut config = test_config(&tmp);
    config.api_url = Some(spawn_auth_me_status(StatusCode::UNAUTHORIZED).await);
    let token = jwt_with_payload(json!({
        "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
    }));

    let err = store_session(&config, &token, None, None)
        .await
        .unwrap_err();

    assert!(
        err.contains("Session validation failed (GET /auth/me)"),
        "expected auth/me validation error, got: {err}"
    );
    let state = auth_get_state(&config).await.unwrap().value;
    assert!(!state.is_authenticated);
}

// ── store_session (local session) ─────────────────────────────

/// A local session token requires a non-empty user payload — the backend
/// fetch path is bypassed entirely, so there is no fallback to derive the
/// user from an API response.
#[tokio::test]
async fn store_session_local_token_rejects_missing_user_payload() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let config = test_config(&tmp);
    let local_token = "header.payload.local";
    let err = store_session(&config, local_token, None, None)
        .await
        .unwrap_err();
    assert!(
        err.contains("local session requires a user payload"),
        "expected 'local session requires a user payload', got: {err}"
    );
}

/// A local session token with a user payload must be accepted without any
/// network call, must force a deterministic `local-<device>` user id
/// regardless of what the caller passes, and must return a stored profile
/// summary.
#[tokio::test]
async fn store_session_local_token_succeeds_without_network_and_forces_local_user_id() {
    let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().unwrap();
    std::fs::create_dir_all(tmp.path().join("workspace")).unwrap();
    let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
    let config = test_config(&tmp);
    let local_token = "header.payload.local";
    let user = serde_json::json!({
        "id": "local",
        "name": "Local User",
        "email": "local@openhuman.local"
    });
    // Pass a different user_id to verify it is overridden.
    let result = store_session(
        &config,
        local_token,
        Some("should-be-overridden".to_string()),
        Some(user),
    )
    .await
    .unwrap();
    // Profile must be stored (no network call was required).
    assert!(
        result.value.has_token,
        "local session should result in a stored token"
    );
    // Logs must mention that backend validation was skipped.
    let log_text = result.logs.join(" ");
    assert!(
        log_text.contains("local session accepted without backend validation"),
        "expected log confirming no backend call, got: {log_text}"
    );
    let expected_local_user_id = local_session_user_id();
    assert!(
        log_text.contains(&format!(
            "user directory activated for {expected_local_user_id}"
        )),
        "expected user-directory activation log for deterministic local uid, got: {log_text}"
    );
    assert!(
        log_text.contains("onboarding left incomplete for local session setup"),
        "expected local session to remain in onboarding, got: {log_text}"
    );
    // The profile_id or metadata must reflect the forced user_id.
    // Because store_session re-activates the user directory and reloads
    // config (so it picks up the user-scoped workspace path), we verify
    // via the returned profile summary rather than a secondary profile lookup.
    assert_eq!(
        result.value.provider, "app-session",
        "profile must be stored under the app-session provider"
    );
}

#[test]
fn normalize_local_session_user_overwrites_id_fields() {
    let out = normalize_local_session_user(
        json!({
            "id": "old",
            "_id": "old",
            "name": "Local User"
        }),
        "local-device-123",
    );

    assert_eq!(out["id"], "local-device-123");
    assert_eq!(out["_id"], "local-device-123");
    assert_eq!(out["name"], "Local User");
}

// ── clear_session ──────────────────────────────────────────────

#[tokio::test]
async fn clear_session_on_empty_store_reports_removed_false() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let result = clear_session(&config).await.unwrap();
    assert_eq!(result.value["removed"], false);
}

// ── auth_get_state / auth_get_session_token_json ──────────────

#[tokio::test]
async fn auth_get_state_reflects_empty_store() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let state = auth_get_state(&config).await.unwrap();
    assert!(!state.value.is_authenticated);
    assert!(state.value.profile_id.is_none());
}

#[tokio::test]
async fn auth_get_session_token_json_returns_null_when_empty() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let out = auth_get_session_token_json(&config).await.unwrap();
    assert!(out.value["token"].is_null());
}

// ── consume_login_token (input validation) ────────────────────

#[tokio::test]
async fn consume_login_token_rejects_empty() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = consume_login_token(&config, "  ").await.unwrap_err();
    assert!(err.contains("loginToken is required"));
}

// ── auth_create_channel_link_token (validation) ───────────────

#[tokio::test]
async fn auth_create_channel_link_token_rejects_empty_channel() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = auth_create_channel_link_token(&config, "   ")
        .await
        .unwrap_err();
    assert!(err.contains("channel is required"));
}

#[tokio::test]
async fn auth_create_channel_link_token_rejects_unsupported_channel() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = auth_create_channel_link_token(&config, "Slack")
        .await
        .unwrap_err();
    assert!(err.contains("unsupported channel"));
}

// ── store_provider_credentials (validation + store path) ──────

#[tokio::test]
async fn store_provider_credentials_rejects_empty_provider() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = store_provider_credentials(&config, "  ", None, None, None, None)
        .await
        .unwrap_err();
    assert!(err.contains("provider is required"));
}

#[tokio::test]
async fn store_provider_credentials_rejects_when_no_credentials_supplied() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = store_provider_credentials(&config, "openai", None, None, None, None)
        .await
        .unwrap_err();
    assert!(err.contains("at least one credential"));
}

#[tokio::test]
async fn store_provider_credentials_rejects_blank_token_without_fields() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = store_provider_credentials(&config, "openai", None, Some("   ".into()), None, None)
        .await
        .unwrap_err();
    assert!(err.contains("at least one credential"));
}

#[tokio::test]
async fn store_provider_credentials_stores_token_and_persists_to_disk() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let result = store_provider_credentials(
        &config,
        "openai",
        Some("default"),
        Some("sk-test".into()),
        None,
        Some(true),
    )
    .await
    .unwrap();
    assert_eq!(result.value.provider, "openai");
    assert_eq!(result.value.profile_name, "default");
    assert!(result.value.has_token);

    let listed = list_provider_credentials(&config, None).await.unwrap();
    assert_eq!(listed.value.len(), 1);
    assert_eq!(listed.value[0].provider, "openai");
}

#[tokio::test]
async fn store_provider_credentials_extracts_token_from_fields() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let result = store_provider_credentials(
        &config,
        "openai",
        None,
        None,
        Some(json!({ "token": "from-fields", "extra": "value" })),
        None,
    )
    .await
    .unwrap();
    assert!(result.value.has_token);
}

#[tokio::test]
async fn store_provider_credentials_extracts_api_key_from_fields() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let result = store_provider_credentials(
        &config,
        "openai",
        None,
        None,
        Some(json!({ "api_key": "from-api-key-field" })),
        None,
    )
    .await
    .unwrap();
    assert!(result.value.has_token);
}

#[tokio::test]
async fn store_provider_credentials_accepts_fields_only_without_token() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    // Non-empty fields but no token — should succeed as "credential via fields".
    let result = store_provider_credentials(
        &config,
        "custom",
        None,
        None,
        Some(json!({ "api_url": "https://custom.example" })),
        None,
    )
    .await
    .unwrap();
    assert_eq!(result.value.provider, "custom");
}

// ── remove_provider_credentials ────────────────────────────────

#[tokio::test]
async fn remove_provider_credentials_reports_false_when_missing() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let result = remove_provider_credentials(&config, "nope", None)
        .await
        .unwrap();
    assert_eq!(result.value["removed"], false);
}

#[tokio::test]
async fn remove_provider_credentials_reports_true_after_store() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    store_provider_credentials(&config, "openai", None, Some("sk".into()), None, Some(true))
        .await
        .unwrap();
    let result = remove_provider_credentials(&config, "openai", None)
        .await
        .unwrap();
    assert_eq!(result.value["removed"], true);
}

// ── list_provider_credentials ─────────────────────────────────

#[tokio::test]
async fn list_provider_credentials_is_empty_for_fresh_store() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let result = list_provider_credentials(&config, None).await.unwrap();
    assert!(result.value.is_empty());
}

#[tokio::test]
async fn list_provider_credentials_filters_by_provider_and_excludes_app_session() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    // Seed openai + anthropic + an app-session entry.
    store_provider_credentials(&config, "openai", None, Some("sk".into()), None, Some(true))
        .await
        .unwrap();
    store_provider_credentials(
        &config,
        "anthropic",
        None,
        Some("sk-ant".into()),
        None,
        Some(true),
    )
    .await
    .unwrap();
    let auth = AuthService::from_config(&config);
    auth.store_provider_token(
        APP_SESSION_PROVIDER,
        DEFAULT_AUTH_PROFILE_NAME,
        "jwt-token",
        std::collections::HashMap::new(),
        true,
    )
    .unwrap();

    let all = list_provider_credentials(&config, None).await.unwrap();
    let providers: Vec<&str> = all.value.iter().map(|p| p.provider.as_str()).collect();
    assert!(providers.contains(&"openai"));
    assert!(providers.contains(&"anthropic"));
    // app-session profile must be excluded from the listing.
    assert!(!providers.contains(&APP_SESSION_PROVIDER));

    let filtered = list_provider_credentials(&config, Some("openai".into()))
        .await
        .unwrap();
    assert_eq!(filtered.value.len(), 1);
    assert_eq!(filtered.value[0].provider, "openai");
}

#[tokio::test]
async fn list_provider_credentials_sorts_by_provider_then_profile_name() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    store_provider_credentials(
        &config,
        "zeta",
        Some("one"),
        Some("t".into()),
        None,
        Some(true),
    )
    .await
    .unwrap();
    store_provider_credentials(
        &config,
        "alpha",
        Some("b"),
        Some("t".into()),
        None,
        Some(true),
    )
    .await
    .unwrap();
    store_provider_credentials(
        &config,
        "alpha",
        Some("a"),
        Some("t".into()),
        None,
        Some(true),
    )
    .await
    .unwrap();

    let all = list_provider_credentials(&config, None).await.unwrap();
    assert_eq!(all.value.len(), 3);
    assert_eq!(all.value[0].provider, "alpha");
    assert_eq!(all.value[0].profile_name, "a");
    assert_eq!(all.value[1].provider, "alpha");
    assert_eq!(all.value[1].profile_name, "b");
    assert_eq!(all.value[2].provider, "zeta");
}

// ── oauth_* (validation paths that don't require network) ─────

#[tokio::test]
async fn oauth_connect_errors_without_session_token() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = oauth_connect(&config, "notion", None, None, None)
        .await
        .unwrap_err();
    assert!(err.contains("session JWT required"));
}

#[tokio::test]
async fn oauth_list_integrations_errors_without_session() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = oauth_list_integrations(&config).await.unwrap_err();
    assert!(err.contains("session JWT required"));
}

#[tokio::test]
async fn oauth_fetch_integration_tokens_errors_without_session() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = oauth_fetch_integration_tokens(&config, "int-1", "enc-key")
        .await
        .unwrap_err();
    assert!(err.contains("session JWT required"));
}

#[tokio::test]
async fn oauth_fetch_client_key_errors_without_session() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = oauth_fetch_client_key(&config, "int-1").await.unwrap_err();
    assert!(err.contains("session JWT required"));
}

#[tokio::test]
async fn oauth_revoke_integration_errors_without_session() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = oauth_revoke_integration(&config, "int-1")
        .await
        .unwrap_err();
    assert!(err.contains("session JWT required"));
}

#[tokio::test]
async fn auth_get_me_errors_without_session() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);
    let err = auth_get_me(&config).await.unwrap_err();
    assert!(err.contains("session JWT required"));
}

// ── list_provider_credentials_by_prefix ───────────────────────

/// Issue #1149 root-cause regression: the exact-match filter on
/// `list_provider_credentials` cannot enumerate provider keys grouped
/// under a common stem (e.g. `channel:telegram:managed_dm`,
/// `channel:slack:bot_token`). The prefix variant fixes that — without
/// it, `channel_status` always returned `connected: false`.
#[tokio::test]
async fn list_provider_credentials_by_prefix_matches_namespaced_keys() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);

    for provider in [
        "channel:telegram:managed_dm",
        "channel:slack:bot_token",
        "skill:notion",
    ] {
        store_provider_credentials(
            &config,
            provider,
            None,
            Some("token-x".to_string()),
            None,
            Some(true),
        )
        .await
        .expect("seed credential");
    }

    let channels = list_provider_credentials_by_prefix(&config, "channel:")
        .await
        .expect("prefix list should succeed");
    let providers: Vec<&str> = channels.iter().map(|p| p.provider.as_str()).collect();

    assert_eq!(channels.len(), 2, "got {providers:?}");
    assert!(providers.contains(&"channel:slack:bot_token"));
    assert!(providers.contains(&"channel:telegram:managed_dm"));
}

#[tokio::test]
async fn list_provider_credentials_by_prefix_returns_empty_when_no_match() {
    let tmp = TempDir::new().unwrap();
    let config = test_config(&tmp);

    store_provider_credentials(
        &config,
        "skill:notion",
        None,
        Some("token-x".to_string()),
        None,
        Some(true),
    )
    .await
    .expect("seed credential");

    let result = list_provider_credentials_by_prefix(&config, "channel:")
        .await
        .expect("prefix list should succeed");
    assert!(result.is_empty(), "got {result:?}");
}

// ── Account-scoped storage isolation ──────────────────────────────────────
//
// The credential store is scoped to `config.workspace_dir` / `config.config_path`.
// Two configs pointing at different directories must not share credential data.
// This models the multi-account scenario: each user account activates a
// different `workspace_dir`, so credentials stored under one account must be
// completely invisible to a different account's config.

#[tokio::test]
async fn credentials_stored_under_one_workspace_dir_invisible_to_another() {
    let tmp_a = TempDir::new().unwrap();
    let tmp_b = TempDir::new().unwrap();
    let config_a = test_config(&tmp_a);
    let config_b = test_config(&tmp_b);

    // Store an OpenAI credential under account A.
    store_provider_credentials(
        &config_a,
        "openai",
        Some("default"),
        Some("sk-account-a".to_string()),
        None,
        Some(true),
    )
    .await
    .expect("store under config_a");

    // Account B's store must be empty — it has its own workspace_dir.
    let listed_b = list_provider_credentials(&config_b, None)
        .await
        .expect("list from config_b");
    assert!(
        listed_b.value.is_empty(),
        "credentials from account A must not be visible to account B, got: {:?}",
        listed_b.value
    );
}

#[tokio::test]
async fn clear_session_on_one_account_does_not_affect_another() {
    let tmp_a = TempDir::new().unwrap();
    let tmp_b = TempDir::new().unwrap();
    let config_a = test_config(&tmp_a);
    let config_b = test_config(&tmp_b);

    // Store an OpenAI credential under each account.
    store_provider_credentials(
        &config_a,
        "openai",
        None,
        Some("sk-a".to_string()),
        None,
        Some(true),
    )
    .await
    .unwrap();
    store_provider_credentials(
        &config_b,
        "openai",
        None,
        Some("sk-b".to_string()),
        None,
        Some(true),
    )
    .await
    .unwrap();

    // Clearing the session for account A must not wipe account B's credentials.
    clear_session(&config_a).await.unwrap();

    let listed_b = list_provider_credentials(&config_b, None)
        .await
        .expect("list from config_b after clear_session on config_a");
    assert_eq!(
        listed_b.value.len(),
        1,
        "account B credential must survive clear_session on account A"
    );
    assert_eq!(listed_b.value[0].provider, "openai");
}

#[tokio::test]
async fn each_account_workspace_holds_its_own_credential_data() {
    // Two accounts store credentials under distinct workspace dirs.
    // Listing with each config must see only its own data, never the other's.
    let tmp_a = TempDir::new().unwrap();
    let tmp_b = TempDir::new().unwrap();
    let config_a = test_config(&tmp_a);
    let config_b = test_config(&tmp_b);

    store_provider_credentials(
        &config_a,
        "anthropic",
        None,
        Some("sk-ant-a".to_string()),
        None,
        Some(true),
    )
    .await
    .unwrap();
    store_provider_credentials(
        &config_b,
        "anthropic",
        None,
        Some("sk-ant-b".to_string()),
        None,
        Some(true),
    )
    .await
    .unwrap();

    let result_a = list_provider_credentials(&config_a, Some("anthropic".into()))
        .await
        .unwrap();
    let result_b = list_provider_credentials(&config_b, Some("anthropic".into()))
        .await
        .unwrap();

    assert_eq!(
        result_a.value.len(),
        1,
        "config_a must see exactly its own anthropic credential"
    );
    assert_eq!(
        result_b.value.len(),
        1,
        "config_b must see exactly its own anthropic credential"
    );
    // Sanity: both found their own entry, neither crossed over.
    assert_eq!(result_a.value[0].provider, "anthropic");
    assert_eq!(result_b.value[0].provider, "anthropic");
}
