//! `docs::` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `DocsService` impl. Mirrors `tests/sdk_kv.rs`:
//! `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 chrono::Utc;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
    AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
    NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId,
    ScriptSandbox, SdkCallCx, Services,
};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use uuid::Uuid;

#[derive(Default)]
struct InMemoryDocs {
    data: Mutex<HashMap<(AppId, String, DocId), DocRow>>,
}

#[async_trait]
impl DocsService for InMemoryDocs {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        data: Value,
    ) -> Result<DocId, DocsError> {
        if !data.is_object() {
            return Err(DocsError::InvalidData);
        }
        let id = Uuid::new_v4();
        let now = Utc::now();
        let row = DocRow {
            id,
            data,
            created_at: now,
            updated_at: now,
        };
        self.data
            .lock()
            .await
            .insert((cx.app_id, collection.to_string(), id), row);
        Ok(id)
    }

    async fn get(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: DocId,
    ) -> Result<Option<DocRow>, DocsError> {
        Ok(self
            .data
            .lock()
            .await
            .get(&(cx.app_id, collection.to_string(), id))
            .cloned())
    }

    async fn find(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        filter: Value,
    ) -> Result<Vec<DocRow>, DocsError> {
        // Tiny eval: extract top-level equalities + $in arrays + $gt
        // (text lex) so the bridge tests can run end-to-end against a
        // fake. This fake mirrors the real service's reject-unsupported
        // contract so the v1.2-pointer-error test goes through the
        // bridge's error-propagation path.
        let map = self.data.lock().await;
        let obj = filter
            .as_object()
            .ok_or_else(|| DocsError::InvalidFilter("filter must be a map/object".into()))?;
        reject_unsupported_operators(obj)?;
        let mut out: Vec<DocRow> = map
            .iter()
            .filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
            .map(|(_, v)| v.clone())
            .filter(|row| matches_simple(&row.data, obj))
            .collect();
        if let Some(limit) = obj.get("$limit").and_then(Value::as_u64) {
            out.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
        }
        Ok(out)
    }

    async fn find_one(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        filter: Value,
    ) -> Result<Option<DocRow>, DocsError> {
        Ok(self.find(cx, collection, filter).await?.into_iter().next())
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: DocId,
        data: Value,
    ) -> Result<(), DocsError> {
        if !data.is_object() {
            return Err(DocsError::InvalidData);
        }
        let mut map = self.data.lock().await;
        let key = (cx.app_id, collection.to_string(), id);
        let Some(row) = map.get_mut(&key) else {
            return Err(DocsError::NotFound);
        };
        row.data = data;
        row.updated_at = Utc::now();
        Ok(())
    }

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

    async fn list(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        _cursor: Option<&str>,
        _limit: u32,
    ) -> Result<DocsListPage, DocsError> {
        let mut docs: Vec<DocRow> = self
            .data
            .lock()
            .await
            .iter()
            .filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
            .map(|(_, v)| v.clone())
            .collect();
        docs.sort_by_key(|d| d.id);
        Ok(DocsListPage {
            docs,
            next_cursor: None,
        })
    }
}

/// Scan an operator object for any `$xxx` key not in the v1.1.2
/// allowlist and return the same shape of error the real parser
/// emits. Top-level `$limit` is the only allowed modifier the fake
/// engages with; the unsupported test passes `$regex`.
fn reject_unsupported_operators(obj: &serde_json::Map<String, Value>) -> Result<(), DocsError> {
    const SUPPORTED_TOP_LEVEL: &[&str] = &["$limit", "$sort"];
    const SUPPORTED_NESTED: &[&str] = &["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"];
    for (key, value) in obj {
        if let Some(stripped) = key.strip_prefix('$') {
            if !SUPPORTED_TOP_LEVEL.contains(&key.as_str()) {
                return Err(DocsError::UnsupportedOperator(format!(
                    "docs::find: top-level modifier '${stripped}' is not supported in v1.1.2; planned for v1.2 advanced query"
                )));
            }
            continue;
        }
        if let Some(inner) = value.as_object() {
            for op_key in inner.keys() {
                if op_key.starts_with('$') && !SUPPORTED_NESTED.contains(&op_key.as_str()) {
                    return Err(DocsError::UnsupportedOperator(format!(
                        "docs::find: operator '{op_key}' is not supported in v1.1.2; planned for v1.2 advanced query"
                    )));
                }
            }
        }
    }
    Ok(())
}

fn matches_simple(data: &Value, filter: &serde_json::Map<String, Value>) -> bool {
    for (key, want) in filter {
        if key.starts_with('$') {
            // $limit handled in the find body.
            continue;
        }
        let actual = data.get(key);
        if let Some(obj) = want.as_object() {
            // operator object — handle $in and $gt only (enough for
            // the bridge tests to exercise the round-trip).
            if let Some(arr) = obj.get("$in").and_then(Value::as_array) {
                let Some(actual) = actual else {
                    return false;
                };
                if !arr.iter().any(|v| v == actual) {
                    return false;
                }
                continue;
            }
            if let Some(gt) = obj.get("$gt") {
                let Some(actual) = actual else {
                    return false;
                };
                let a = actual.as_str().unwrap_or("");
                let b = gt.as_str().unwrap_or("");
                if a <= b {
                    return false;
                }
                continue;
            }
            return false;
        }
        if Some(want) != actual {
            return false;
        }
    }
    true
}

fn make_engine() -> Arc<Engine> {
    let services = Services::new(
        Arc::new(NoopKvService),
        Arc::new(InMemoryDocs::default()),
        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: "docs-test".into(),
        invocation_type: InvocationType::Http,
        path: "/docs-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 docs_create_then_get_round_trip() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let users = docs::collection("users");
        let id = users.create(#{ name: "Alice", tier: "gold" });
        let doc = users.get(id);
        #{ id_matches: doc.id == id, data_name: doc.data.name }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    let obj = body.as_object().unwrap();
    assert_eq!(obj["id_matches"], json!(true));
    assert_eq!(obj["data_name"], json!("Alice"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_get_missing_returns_unit() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        let v = c.get("00000000-0000-0000-0000-000000000000");
        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 docs_get_with_invalid_uuid_throws() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"docs::collection("users").get("not-a-uuid")"#;
    let req = baseline_request(app);
    let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
        .await
        .unwrap()
        .expect_err("invalid uuid should throw");
    assert!(format!("{err:?}").contains("invalid id"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_find_equality_returns_matches() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        c.create(#{ tier: "gold" });
        c.create(#{ tier: "silver" });
        c.create(#{ tier: "gold" });
        let golds = c.find(#{ tier: "gold" });
        golds.len()
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!(2));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_find_with_in_operator() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        c.create(#{ tier: "gold" });
        c.create(#{ tier: "silver" });
        c.create(#{ tier: "platinum" });
        let hits = c.find(#{ tier: #{ "$in": ["gold", "platinum"] } });
        hits.len()
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!(2));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_find_with_gt_comparison() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("events");
        c.create(#{ when: "2026-01-15" });
        c.create(#{ when: "2026-03-15" });
        c.create(#{ when: "2026-05-15" });
        let recent = c.find(#{ when: #{ "$gt": "2026-02-01" } });
        recent.len()
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!(2));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_find_one_returns_envelope_or_unit() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        c.create(#{ tier: "gold" });
        let hit = c.find_one(#{ tier: "gold" });
        let miss = c.find_one(#{ tier: "platinum" });
        #{ hit_has_data: hit.data.tier == "gold", miss_is_unit: miss == () }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    let obj = body.as_object().unwrap();
    assert_eq!(obj["hit_has_data"], json!(true));
    assert_eq!(obj["miss_is_unit"], json!(true));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_update_then_get_reflects_change() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        let id = c.create(#{ name: "Alice", tier: "gold" });
        c.update(id, #{ name: "Alice", tier: "platinum" });
        c.get(id).data.tier
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!("platinum"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_update_missing_throws() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        c.update("00000000-0000-0000-0000-000000000000", #{ x: 1 })
    "#;
    let req = baseline_request(app);
    let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
        .await
        .unwrap()
        .expect_err("update missing should throw");
    assert!(format!("{err:?}").contains("not found"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_delete_returns_was_present() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        let nope = c.delete("00000000-0000-0000-0000-000000000000");
        let id = c.create(#{ x: 1 });
        let yep = c.delete(id);
        #{ 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 docs_unsupported_operator_throws_with_v1_2_pointer() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        c.find(#{ name: #{ "$regex": "^A" } })
    "#;
    let req = baseline_request(app);
    let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
        .await
        .unwrap()
        .expect_err("unsupported operator should throw");
    let msg = format!("{err:?}");
    assert!(msg.contains("$regex"), "msg: {msg}");
    assert!(msg.contains("v1.2"), "msg: {msg}");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_empty_collection_name_throws() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"docs::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("docs::collection"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_list_returns_docs_array() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        c.create(#{ a: 1 });
        c.create(#{ a: 2 });
        let page = c.list();
        page.docs.len()
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!(2));
}

/// Cross-app isolation through the bridge — script with `app_id = A`
/// must NOT see documents written from `app_id = B` even when the
/// (collection, id) tuple is shared. The bridge captures `cx.app_id`
/// via `Arc<SdkCallCx>` and the service derives storage `app_id` from
/// it (never from a script arg).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_bridge_preserves_cross_app_isolation() {
    let engine = make_engine();
    let app_a = AppId::new();
    let app_b = AppId::new();

    let writer = r#"
        let c = docs::collection("shared");
        let id = c.create(#{ from: "a" });
        id
    "#;
    let id_a = run_script(engine.clone(), writer, baseline_request(app_a)).await;
    let id_a_str = id_a.as_str().unwrap().to_string();

    // App B looks up the same id under the same collection — should
    // see nothing because the service keyed it by app_id = A.
    let reader_src = format!(
        r#"
        let c = docs::collection("shared");
        let v = c.get("{id_a_str}");
        v == ()
    "#
    );
    let body = run_script(engine.clone(), &reader_src, baseline_request(app_b)).await;
    assert_eq!(body, json!(true), "app B must not see app A's document");

    // Positive control — without it this test has no teeth (an execution_id-keyed
    // bridge would pass the negative above). `baseline_request` mints a fresh
    // execution_id/script_id per call, so this re-read under app_a is a different
    // execution than the writer; it must still find A's own doc.
    let reader_a = format!(
        r#"
        let c = docs::collection("shared");
        let v = c.get("{id_a_str}");
        if v == () {{ "MISSING" }} else {{ v.data.from }}
    "#
    );
    let body = run_script(engine, &reader_a, baseline_request(app_a)).await;
    assert_eq!(
        body,
        json!("a"),
        "app A must read back its own document in a later execution — storage is \
         keyed by app_id, not execution_id/script_id"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docs_envelope_has_id_data_created_at_updated_at() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = docs::collection("users");
        let id = c.create(#{ name: "Alice" });
        let doc = c.get(id);
        // Probe each envelope field is present + correctly typed.
        #{
            has_id:         type_of(doc.id) == "string",
            has_data:       type_of(doc.data) == "map",
            has_created_at: type_of(doc.created_at) == "string",
            has_updated_at: type_of(doc.updated_at) == "string",
            user_field:     doc.data.name
        }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    let obj = body.as_object().unwrap();
    assert_eq!(obj["has_id"], json!(true));
    assert_eq!(obj["has_data"], json!(true));
    assert_eq!(obj["has_created_at"], json!(true));
    assert_eq!(obj["has_updated_at"], json!(true));
    assert_eq!(obj["user_field"], json!("Alice"));
}
