//! `pubsub::subscriber_token` SDK bridge integration tests (v1.1.6).
//!
//! Runs a real Rhai engine against a fake `PubsubService` whose
//! `mint_subscriber_token` mirrors the production validation (principal
//! required, non-empty topics, ttl clamp, externally-subscribable check)
//! and signs a real token. These cover the bridge surface: array →
//! `Vec<String>` forwarding, the omitted/`()`/integer ttl handling, and
//! errors surfacing as thrown Rhai errors. The authoritative validation
//! logic is unit-tested in `manager-core::pubsub_service`.

use std::collections::BTreeMap;
use std::sync::Arc;

use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::subscriber_token::{self, TokenClaims};
use picloud_shared::{
    AdminUserId, AppId, ExecutionId, InstanceRole, NoopDeadLetterService, NoopDocsService,
    NoopEventEmitter, NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource,
    Principal, PubsubError, PubsubService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
};
use serde_json::Value;

const FAKE_KEY: [u8; 32] = [7u8; 32];
const MIN_TTL: i64 = 10;
const MAX_TTL: i64 = 86_400;
const DEFAULT_TTL: i64 = 3_600;

/// Fake that mirrors the production mint rules and signs with FAKE_KEY.
#[derive(Default)]
struct FakeMintPubsub;

#[async_trait]
impl PubsubService for FakeMintPubsub {
    async fn publish_durable(
        &self,
        _cx: &SdkCallCx,
        _topic: &str,
        _message: Value,
    ) -> Result<(), PubsubError> {
        Ok(())
    }

    async fn mint_subscriber_token(
        &self,
        cx: &SdkCallCx,
        topics: Vec<String>,
        ttl_seconds: Option<i64>,
    ) -> Result<String, PubsubError> {
        if cx.principal.is_none() {
            return Err(PubsubError::SubscriberToken(
                "pubsub::subscriber_token: requires an authenticated principal".into(),
            ));
        }
        if topics.is_empty() {
            return Err(PubsubError::SubscriberToken(
                "pubsub::subscriber_token: topics list must not be empty".into(),
            ));
        }
        let ttl = ttl_seconds.unwrap_or(DEFAULT_TTL);
        if !(MIN_TTL..=MAX_TTL).contains(&ttl) {
            return Err(PubsubError::SubscriberToken(format!(
                "pubsub::subscriber_token: ttl_seconds must be between {MIN_TTL} and {MAX_TTL}"
            )));
        }
        for name in &topics {
            // Only "chat" and "notify" are "registered" in this fake.
            if name != "chat" && name != "notify" {
                return Err(PubsubError::SubscriberToken(format!(
                    "pubsub::subscriber_token: topic {name} is not externally subscribable"
                )));
            }
        }
        let now = 1_000_000;
        Ok(subscriber_token::sign(
            &FAKE_KEY,
            &TokenClaims {
                app_id: cx.app_id,
                topics,
                exp: now + ttl,
                iat: now,
            },
        ))
    }
}

fn make_engine() -> Arc<Engine> {
    let services = Services::new(
        Arc::new(NoopKvService),
        Arc::new(NoopDocsService),
        Arc::new(NoopDeadLetterService),
        Arc::new(NoopEventEmitter),
        Arc::new(NoopModuleSource),
        Arc::new(NoopHttpService),
        Arc::new(NoopFilesService),
        Arc::new(FakeMintPubsub),
        Arc::new(picloud_shared::NoopSecretsService),
        Arc::new(picloud_shared::NoopEmailService),
        Arc::new(picloud_shared::NoopUsersService),
        Arc::new(picloud_shared::NoopQueueService),
        Arc::new(picloud_shared::NoopInvokeService),
        Arc::new(picloud_shared::NoopVarsService),
    );
    Arc::new(Engine::new(Limits::default(), services))
}

fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
    let execution_id = ExecutionId::new();
    ExecRequest {
        execution_id,
        request_id: RequestId::new(),
        script_id: ScriptId::new(),
        script_name: "token-test".into(),
        invocation_type: InvocationType::Http,
        path: "/token-test".into(),
        method: String::new(),
        headers: BTreeMap::new(),
        body: Value::Null,
        params: BTreeMap::new(),
        query: BTreeMap::new(),
        rest: String::new(),
        sandbox_overrides: ScriptSandbox::default(),
        app_id,
        script_owner: None,
        principal: with_principal.then(|| Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: None,
            app_binding: None,
        }),
        trigger_depth: 0,
        root_execution_id: execution_id,
        is_dead_letter_handler: false,
        event: None,
    }
}

async fn run_ok(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
    let src = src.to_string();
    tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .expect("spawn_blocking should not panic")
        .expect("script execution should succeed")
        .body
}

async fn run_err(engine: Arc<Engine>, src: &str, req: ExecRequest) {
    let src = src.to_string();
    let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .expect("spawn_blocking should not panic");
    assert!(res.is_err(), "expected script to throw");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_contains_topics_and_expiry() {
    let app = AppId::new();
    let body = run_ok(
        make_engine(),
        r#"#{ token: pubsub::subscriber_token(["chat", "notify"], 120) }"#,
        request(app, true),
    )
    .await;
    let token = body["token"].as_str().expect("token string");
    let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
    assert_eq!(claims.app_id, app);
    assert_eq!(
        claims.topics,
        vec!["chat".to_string(), "notify".to_string()]
    );
    assert_eq!(claims.exp - claims.iat, 120);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn omitted_ttl_uses_default() {
    let app = AppId::new();
    let body = run_ok(
        make_engine(),
        r#"#{ token: pubsub::subscriber_token(["chat"]) }"#,
        request(app, true),
    )
    .await;
    let token = body["token"].as_str().unwrap();
    let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
    assert_eq!(claims.exp - claims.iat, DEFAULT_TTL);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unit_ttl_uses_default() {
    let app = AppId::new();
    let body = run_ok(
        make_engine(),
        r#"#{ token: pubsub::subscriber_token(["chat"], ()) }"#,
        request(app, true),
    )
    .await;
    let token = body["token"].as_str().unwrap();
    let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
    assert_eq!(claims.exp - claims.iat, DEFAULT_TTL);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_topics_throws() {
    run_err(
        make_engine(),
        r"pubsub::subscriber_token([], 60)",
        request(AppId::new(), true),
    )
    .await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ttl_below_min_throws() {
    run_err(
        make_engine(),
        r#"pubsub::subscriber_token(["chat"], 5)"#,
        request(AppId::new(), true),
    )
    .await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ttl_above_max_throws() {
    run_err(
        make_engine(),
        r#"pubsub::subscriber_token(["chat"], 90000)"#,
        request(AppId::new(), true),
    )
    .await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn anonymous_principal_throws() {
    run_err(
        make_engine(),
        r#"pubsub::subscriber_token(["chat"], 60)"#,
        request(AppId::new(), false),
    )
    .await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unregistered_topic_throws() {
    run_err(
        make_engine(),
        r#"pubsub::subscriber_token(["chat", "secret"], 60)"#,
        request(AppId::new(), true),
    )
    .await;
}
