//! `kv::` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `KvService` impl. Mirrors how
//! `orchestrator-core::LocalExecutorClient` invokes the engine: under
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
//! reachable runtime.

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

use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
    AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
    NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox,
    SdkCallCx, Services,
};
use serde_json::{json, Value};
use tokio::sync::Mutex;

#[derive(Default)]
struct InMemoryKv {
    data: Mutex<HashMap<(AppId, String, String), Value>>,
}

#[async_trait]
impl KvService for InMemoryKv {
    async fn get(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
    ) -> Result<Option<Value>, KvError> {
        Ok(self
            .data
            .lock()
            .await
            .get(&(cx.app_id, collection.to_string(), key.to_string()))
            .cloned())
    }

    async fn set(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        value: Value,
    ) -> Result<(), KvError> {
        self.data
            .lock()
            .await
            .insert((cx.app_id, collection.to_string(), key.to_string()), value);
        Ok(())
    }

    async fn set_if(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        expected: Option<Value>,
        new: Value,
    ) -> Result<bool, KvError> {
        let mut data = self.data.lock().await;
        let k = (cx.app_id, collection.to_string(), key.to_string());
        let matches = match (&expected, data.get(&k)) {
            (None, None) => true,
            (Some(exp), Some(cur)) => exp == cur,
            _ => false,
        };
        if matches {
            data.insert(k, new);
        }
        Ok(matches)
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
        Ok(self
            .data
            .lock()
            .await
            .remove(&(cx.app_id, collection.to_string(), key.to_string()))
            .is_some())
    }

    async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
        Ok(self.data.lock().await.contains_key(&(
            cx.app_id,
            collection.to_string(),
            key.to_string(),
        )))
    }

    async fn list(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<KvListPage, KvError> {
        let data = self.data.lock().await;
        let mut keys: Vec<String> = data
            .iter()
            .filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
            .map(|((_, _, k), _)| k.clone())
            .filter(|k| cursor.is_none_or(|c| k.as_str() > c))
            .collect();
        keys.sort();
        let take = if limit == 0 {
            usize::MAX
        } else {
            limit as usize
        };
        let next_cursor = if keys.len() > take {
            keys.truncate(take);
            keys.last().cloned()
        } else {
            None
        };
        Ok(KvListPage { keys, next_cursor })
    }
}

fn make_engine() -> Arc<Engine> {
    let services = Services::new(
        Arc::new(InMemoryKv::default()),
        Arc::new(NoopDocsService),
        Arc::new(NoopDeadLetterService),
        Arc::new(NoopEventEmitter),
        Arc::new(NoopModuleSource),
        Arc::new(NoopHttpService),
        Arc::new(picloud_shared::NoopFilesService),
        Arc::new(picloud_shared::NoopPubsubService),
        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 baseline_request(app_id: AppId) -> ExecRequest {
    let execution_id = ExecutionId::new();
    ExecRequest {
        execution_id,
        request_id: RequestId::new(),
        script_id: ScriptId::new(),
        script_name: "kv-test".into(),
        invocation_type: InvocationType::Http,
        path: "/kv-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: None,
        trigger_depth: 0,
        root_execution_id: execution_id,
        is_dead_letter_handler: false,
        event: None,
    }
}

async fn run_script(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
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_then_get_round_trip() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let widgets = kv::collection("widgets");
        widgets.set("k1", #{ n: 1 });
        widgets.get("k1")
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!({ "n": 1 }));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_if_compare_and_swap() {
    let engine = make_engine();
    let app = AppId::new();
    // `set_if(key, (), new)` inserts only when absent; `set_if(key, expected,
    // new)` swaps only on match. Returns a bool each time.
    let src = r#"
        let c = kv::collection("counters");
        let first = c.set_if("n", (), 1);      // absent -> inserts, true
        let dup   = c.set_if("n", (), 2);      // present -> false
        let bad   = c.set_if("n", 99, 3);      // mismatch -> false
        let good  = c.set_if("n", 1, 3);       // match   -> swaps, true
        #{ first: first, dup: dup, bad: bad, good: good, final: c.get("n") }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(
        body,
        json!({ "first": true, "dup": false, "bad": false, "good": true, "final": 3 })
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_get_missing_returns_unit() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = kv::collection("widgets");
        let v = c.get("nope");
        v == ()
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!(true));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_has_returns_bool() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = kv::collection("widgets");
        let before = c.has("k");
        c.set("k", "v");
        let after = c.has("k");
        #{ before: before, after: after }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!({ "before": false, "after": true }));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_delete_returns_was_present() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = kv::collection("widgets");
        let nope = c.delete("missing");
        c.set("k", 1);
        let yep = c.delete("k");
        #{ nope: nope, yep: yep }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!({ "nope": false, "yep": true }));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_empty_collection_name_throws() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"kv::collection("")"#;
    let req = baseline_request(app);
    let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
        .await
        .unwrap()
        .expect_err("empty collection should throw");
    assert!(format!("{err:?}").contains("kv::collection"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_list_pages_via_cursor() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = kv::collection("widgets");
        for i in 0..5 { c.set(`k${i}`, i); }
        let p1 = c.list("", 2);
        let p2 = c.list(p1.next_cursor, 2);
        #{
            p1_keys: p1.keys,
            p1_cursor: p1.next_cursor,
            p2_keys: p2.keys,
        }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    let obj = body.as_object().unwrap();
    let p1_keys = obj["p1_keys"].as_array().unwrap();
    let p2_keys = obj["p2_keys"].as_array().unwrap();
    assert_eq!(p1_keys.len(), 2);
    assert_eq!(p2_keys.len(), 2);
    assert!(obj["p1_cursor"].is_string());
}

/// Cross-app isolation via `cx.app_id` — script with `app_id = A`
/// cannot see entries from `app_id = B`. The kv:: bridge never
/// surfaces `app_id` to the script, so this is enforced purely by the
/// service deriving it from the captured `Arc<SdkCallCx>`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_bridge_preserves_cross_app_isolation() {
    let engine = make_engine();
    let app_a = AppId::new();
    let app_b = AppId::new();

    let writer = r#"
        let c = kv::collection("shared");
        c.set("k", "from-a");
        "ok"
    "#;
    let _ = run_script(engine.clone(), writer, baseline_request(app_a)).await;

    // App B sees nothing under the same collection/key.
    let reader = r#"
        let c = kv::collection("shared");
        c.get("k")
    "#;
    let body = run_script(engine.clone(), reader, baseline_request(app_b)).await;
    assert_eq!(body, Value::Null, "app B must not see app A's value");

    // Positive control — WITHOUT it this test has no teeth. `baseline_request`
    // mints a fresh execution_id AND script_id each call, so this second run under
    // app_a is a DIFFERENT execution/script than the writer. A bridge that keyed
    // storage by execution_id or script_id (instead of app_id) would pass the
    // negative above AND every single-execution round-trip, while app-scoped
    // persistence was completely gone. This catches that: app A must still read
    // back its own value across executions.
    let body = run_script(engine, reader, baseline_request(app_a)).await;
    assert_eq!(
        body,
        Value::from("from-a"),
        "app A must read back its own value in a later execution — storage is keyed \
         by app_id, not execution_id/script_id"
    );
}
