//! `files::` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `FilesService` impl. Mirrors `tests/sdk_kv.rs`:
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
//! reachable runtime. Exercises the actual Rhai surface — blob in/out,
//! the metadata map shape, and the missing-required-field throw.

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

use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
    AppId, ExecutionId, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService, NewFile,
    NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
    NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use uuid::Uuid;

#[derive(Default)]
struct InMemoryFiles {
    #[allow(clippy::type_complexity)]
    data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
}

/// The in-memory fake doesn't exercise the real checksum path (the
/// `FsFilesRepo` tempdir tests in manager-core cover SHA-256); a stable
/// placeholder keeps the metadata map non-empty.
fn fake_checksum(bytes: &[u8]) -> String {
    format!("len-{}", bytes.len())
}

#[async_trait]
impl FilesService for InMemoryFiles {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        new: NewFile,
    ) -> Result<Uuid, FilesError> {
        if collection.is_empty() {
            return Err(FilesError::InvalidCollection("empty".into()));
        }
        new.validate(100 * 1024 * 1024)?;
        let id = Uuid::new_v4();
        let now = chrono::Utc::now();
        let meta = FileMeta {
            id,
            collection: collection.to_string(),
            name: new.name.clone(),
            content_type: new.content_type.clone(),
            size: new.data.len() as u64,
            checksum: fake_checksum(&new.data),
            created_at: now,
            updated_at: now,
        };
        self.data
            .lock()
            .await
            .insert((cx.app_id, collection.to_string(), id), (meta, new.data));
        Ok(id)
    }

    async fn head(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
    ) -> Result<Option<FileMeta>, FilesError> {
        let Ok(uuid) = Uuid::parse_str(id) else {
            return Ok(None);
        };
        Ok(self
            .data
            .lock()
            .await
            .get(&(cx.app_id, collection.to_string(), uuid))
            .map(|(m, _)| m.clone()))
    }

    async fn get(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
    ) -> Result<Option<Vec<u8>>, FilesError> {
        let Ok(uuid) = Uuid::parse_str(id) else {
            return Ok(None);
        };
        Ok(self
            .data
            .lock()
            .await
            .get(&(cx.app_id, collection.to_string(), uuid))
            .map(|(_, b)| b.clone()))
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
        upd: FileUpdate,
    ) -> Result<(), FilesError> {
        upd.validate(100 * 1024 * 1024)?;
        let Ok(uuid) = Uuid::parse_str(id) else {
            return Err(FilesError::NotFound);
        };
        let mut data = self.data.lock().await;
        let key = (cx.app_id, collection.to_string(), uuid);
        let Some((meta, _)) = data.get(&key).cloned() else {
            return Err(FilesError::NotFound);
        };
        let mut meta = meta;
        if let Some(n) = upd.name {
            meta.name = n;
        }
        if let Some(ct) = upd.content_type {
            meta.content_type = ct;
        }
        meta.size = upd.data.len() as u64;
        meta.checksum = fake_checksum(&upd.data);
        data.insert(key, (meta, upd.data));
        Ok(())
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
        let Ok(uuid) = Uuid::parse_str(id) else {
            return Ok(false);
        };
        Ok(self
            .data
            .lock()
            .await
            .remove(&(cx.app_id, collection.to_string(), uuid))
            .is_some())
    }

    async fn list(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        _cursor: Option<&str>,
        _limit: u32,
    ) -> Result<FilesListPage, FilesError> {
        let data = self.data.lock().await;
        let files: Vec<FileMeta> = data
            .iter()
            .filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
            .map(|(_, (m, _))| m.clone())
            .collect();
        Ok(FilesListPage {
            files,
            next_cursor: None,
        })
    }
}

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(InMemoryFiles::default()),
        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: "files-test".into(),
        invocation_type: InvocationType::Http,
        path: "/files-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
}

async fn run_script_err(engine: Arc<Engine>, src: &str, req: ExecRequest) -> String {
    let src = src.to_string();
    let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
        .await
        .expect("spawn_blocking should not panic");
    format!("{:?}", res.expect_err("script should error"))
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_create_get_round_trip_via_blob() {
    let engine = make_engine();
    let app = AppId::new();
    // base64("hello") = "aGVsbG8="; decode → blob; create; get back; encode.
    let src = r#"
        let c = files::collection("avatars");
        let data = base64::decode("aGVsbG8=");
        let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data });
        let back = c.get(id);
        base64::encode(back)
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!("aGVsbG8="));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_head_returns_metadata_map() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = files::collection("avatars");
        let data = base64::decode("aGVsbG8=");
        let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data });
        let meta = c.head(id);
        #{ name: meta.name, content_type: meta.content_type, size: meta.size, has_checksum: meta.checksum != () }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(
        body,
        json!({ "name": "a.txt", "content_type": "text/plain", "size": 5, "has_checksum": true })
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_get_and_head_missing_return_unit() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = files::collection("avatars");
        let g = c.get("00000000-0000-0000-0000-000000000000");
        let h = c.head("00000000-0000-0000-0000-000000000000");
        #{ g: g == (), h: h == () }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!({ "g": true, "h": true }));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_update_then_delete() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = files::collection("avatars");
        let id = c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") });
        c.update(id, #{ data: base64::decode("YmM=") });   // "bc"
        let after = base64::encode(c.get(id));
        let removed = c.delete(id);
        let gone = c.delete(id);
        #{ after: after, removed: removed, gone: gone }
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(
        body,
        json!({ "after": "YmM=", "removed": true, "gone": false })
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_create_missing_data_throws_naming_field() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = files::collection("avatars");
        c.create(#{ name: "a", content_type: "text/plain" })
    "#;
    let err = run_script_err(engine, src, baseline_request(app)).await;
    assert!(
        err.contains("data"),
        "error should name the missing field: {err}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_create_missing_name_throws_naming_field() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = files::collection("avatars");
        c.create(#{ content_type: "text/plain", data: base64::decode("YQ==") })
    "#;
    let err = run_script_err(engine, src, baseline_request(app)).await;
    assert!(
        err.contains("name"),
        "error should name the missing field: {err}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_empty_collection_name_throws() {
    let engine = make_engine();
    let app = AppId::new();
    let err = run_script_err(engine, r#"files::collection("")"#, baseline_request(app)).await;
    assert!(err.to_lowercase().contains("empty"), "got {err}");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_list_returns_files_array() {
    let engine = make_engine();
    let app = AppId::new();
    let src = r#"
        let c = files::collection("avatars");
        c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") });
        c.create(#{ name: "b", content_type: "text/plain", data: base64::decode("Yg==") });
        let page = c.list();
        page.files.len()
    "#;
    let body = run_script(engine, src, baseline_request(app)).await;
    assert_eq!(body, json!(2));
}
