//! `invoke()` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `InvokeService` that fakes script resolution.
//! Verifies sync re-entry via `Engine::set_self_weak`, the cx-threading
//! that gives the callee `trigger_depth + 1`, cross-app rejection, depth
//! limit, FnPtr-in-args rejection, and `invoke_async` payload shape.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use chrono::Utc;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
    AppId, ExecutionId, InvokeError, InvokeService, InvokeTarget, NoopDeadLetterService,
    NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopHttpService,
    NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService,
    NoopUsersService, RequestId, ResolvedScript, ScriptId, ScriptSandbox, SdkCallCx, Services,
};
use serde_json::{json, Value};

#[derive(Default)]
struct FakeInvokeService {
    scripts: Mutex<Vec<(ScriptId, AppId, String, String)>>, // (id, app, name, source)
    async_payloads: Mutex<Vec<Value>>,
}

impl FakeInvokeService {
    fn register(&self, app: AppId, name: &str, source: &str) -> ScriptId {
        let id = ScriptId::new();
        self.scripts
            .lock()
            .unwrap()
            .push((id, app, name.to_string(), source.to_string()));
        id
    }
}

#[async_trait]
impl InvokeService for FakeInvokeService {
    async fn resolve(
        &self,
        cx: &SdkCallCx,
        target: InvokeTarget,
    ) -> Result<ResolvedScript, InvokeError> {
        let entries = self.scripts.lock().unwrap().clone();
        let hit = entries.iter().find(|(id, _app, name, _)| match &target {
            InvokeTarget::Id(t) => t == id,
            // Test stashes route paths in the `name` column, so Path
            // and Name match the same field.
            InvokeTarget::Name(t) | InvokeTarget::Path(t) => t == name,
        });
        let (id, app, name, source): (ScriptId, AppId, String, String) = hit
            .ok_or_else(|| InvokeError::NotFound(target.describe()))?
            .clone();
        if app != cx.app_id {
            return Err(InvokeError::CrossApp);
        }
        Ok(ResolvedScript {
            script_id: id,
            app_id: app,
            owner: None,
            source,
            updated_at: Utc::now(),
            name,
        })
    }

    async fn enqueue_async(
        &self,
        _cx: &SdkCallCx,
        _target: InvokeTarget,
        args: Value,
    ) -> Result<ExecutionId, InvokeError> {
        self.async_payloads.lock().unwrap().push(args);
        Ok(ExecutionId::new())
    }
}

fn build_engine(svc: Arc<FakeInvokeService>) -> 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(NoopPubsubService),
        Arc::new(NoopSecretsService),
        Arc::new(NoopEmailService),
        Arc::new(NoopUsersService),
        Arc::new(NoopQueueService),
        svc,
        Arc::new(picloud_shared::NoopVarsService),
    );
    let engine = Arc::new(Engine::new(Limits::default(), services));
    engine.set_self_weak(Arc::downgrade(&engine));
    engine
}

fn baseline_request(app_id: AppId) -> ExecRequest {
    let execution_id = ExecutionId::new();
    ExecRequest {
        execution_id,
        request_id: RequestId::new(),
        script_id: ScriptId::new(),
        script_name: "caller".into(),
        invocation_type: InvocationType::Http,
        path: "/caller".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: None,
        trigger_depth: 0,
        root_execution_id: execution_id,
        is_dead_letter_handler: false,
        event: None,
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invoke_returns_callee_value() {
    let app = AppId::new();
    let svc = Arc::new(FakeInvokeService::default());
    svc.register(app, "worker", "ctx.request.body.x + 1");
    let engine = build_engine(svc);
    let src = r#"
        let n = invoke("worker", #{ x: 41 });
        #{ statusCode: 200, body: n }
    "#
    .to_string();
    let req = baseline_request(app);
    let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .unwrap()
        .unwrap();
    assert_eq!(resp.body, json!(42));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invoke_cross_app_rejects() {
    let caller_app = AppId::new();
    let other_app = AppId::new();
    let svc = Arc::new(FakeInvokeService::default());
    svc.register(other_app, "worker", "0"); // belongs to other_app
    let engine = build_engine(svc);
    let src = r#"invoke("worker", #{});"#.to_string();
    let req = baseline_request(caller_app);
    let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .unwrap();
    let err = res.unwrap_err().to_string();
    assert!(
        err.contains("different app") || err.contains("CrossApp"),
        "expected cross-app rejection, got: {err}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invoke_depth_limit_exceeded() {
    let app = AppId::new();
    let svc = Arc::new(FakeInvokeService::default());
    // recursive: calls itself again — would loop without the bound.
    svc.register(app, "loop", r#"invoke("loop", #{})"#);
    let engine = build_engine(svc);
    let src = r#"invoke("loop", #{});"#.to_string();
    let req = baseline_request(app);
    let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .unwrap();
    let err = res.unwrap_err().to_string();
    assert!(
        err.contains("depth limit"),
        "expected depth limit error, got: {err}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invoke_callee_sees_incremented_depth() {
    let app = AppId::new();
    let svc = Arc::new(FakeInvokeService::default());
    // The callee returns its own trigger_depth so the caller can assert
    // it bumped from 0 → 1.
    svc.register(app, "depth_probe", "ctx.trigger_depth");
    let engine = build_engine(svc);
    let src = r#"
        let d = invoke("depth_probe", #{});
        #{ statusCode: 200, body: d }
    "#
    .to_string();
    // The caller is direct ingress (depth 0); the callee, re-entered via
    // `invoke`, must see depth 1. Previously this test asserted NOTHING (`let _ =
    // resp`) because `ctx.trigger_depth` wasn't exposed — so it would have passed
    // even if `invoke` forwarded the depth unchanged (never incrementing the
    // chain-depth counter that bounds runaway trigger loops). `ctx.trigger_depth`
    // is now surfaced in build_ctx_map, so we can pin the increment.
    let req = baseline_request(app);
    assert_eq!(req.trigger_depth, 0, "the caller is direct ingress");
    let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .unwrap()
        .unwrap();
    assert_eq!(
        resp.body,
        json!(1),
        "the invoke callee must see trigger_depth = caller + 1"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invoke_rejects_fnptr_in_args() {
    let app = AppId::new();
    let svc = Arc::new(FakeInvokeService::default());
    svc.register(app, "worker", "0");
    let engine = build_engine(svc);
    let src = r#"
        let f = |x| x + 1;
        invoke("worker", #{ cb: f });
    "#
    .to_string();
    let req = baseline_request(app);
    let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .unwrap();
    let err = res.unwrap_err().to_string();
    assert!(
        err.contains("FnPtr") || err.contains("closure"),
        "expected FnPtr rejection, got: {err}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invoke_async_returns_execution_id_string() {
    let app = AppId::new();
    let svc = Arc::new(FakeInvokeService::default());
    svc.register(app, "worker", "0");
    let engine = build_engine(svc.clone());
    let src = r#"
        let id = invoke_async("worker", #{ x: 1 });
        #{ statusCode: 200, body: id }
    "#
    .to_string();
    let req = baseline_request(app);
    let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .unwrap()
        .unwrap();
    // Body is a string — a UUID — surfaced as Json::String.
    let id = resp.body.as_str().expect("body should be a string");
    assert!(
        uuid::Uuid::parse_str(id).is_ok(),
        "expected UUID, got: {id}"
    );
    let payloads = svc.async_payloads.lock().unwrap().clone();
    assert_eq!(payloads.len(), 1);
    assert_eq!(payloads[0], json!({ "x": 1 }));
}
