//! `FilesServiceImpl` — wires the `FilesRepo` underneath the
//! `picloud_shared::FilesService` trait scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo), mirroring `KvServiceImpl`:
//!   1. Collection validation (empty + path-traversal) and field /
//!      size-cap validation at the SDK boundary.
//!   2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//!      `authz::require(...)`; when it's `None` (public HTTP) we skip.
//!      Cross-app isolation is unaffected — every repo call is keyed by
//!      `cx.app_id`, never an argument.
//!   3. `ServiceEvent` emission after each mutation (`create` /
//!      `update` / `delete`). The payload is the file **metadata**, not
//!      the blob bytes (files are too big for trigger payloads).

use std::sync::Arc;

use async_trait::async_trait;
use picloud_shared::{
    sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesError,
    FilesListPage, FilesService, NewFile, SdkCallCx, ServiceEventEmitter,
};
use uuid::Uuid;

use crate::atomic_write::{BestEffortFilesWriter, FilesWriter, PostgresFilesWriter};
use crate::authz::{self, AuthzRepo, Capability};
use crate::files_repo::{FilesRepo, FilesRepoError};

pub struct FilesServiceImpl {
    repo: Arc<dyn FilesRepo>,
    authz: Arc<dyn AuthzRepo>,
    writer: Arc<dyn FilesWriter>,
    max_file_size_bytes: usize,
}

impl FilesServiceImpl {
    #[must_use]
    pub fn new(
        repo: Arc<dyn FilesRepo>,
        authz: Arc<dyn AuthzRepo>,
        events: Arc<dyn ServiceEventEmitter>,
        max_file_size_bytes: usize,
    ) -> Self {
        Self {
            writer: Arc::new(BestEffortFilesWriter::new(repo.clone(), events)),
            repo,
            authz,
            max_file_size_bytes,
        }
    }

    /// Swap the best-effort writer for the transactional one: the metadata row
    /// and the trigger fan-out then commit together, so an outbox failure rolls
    /// the metadata back (and unlinks the blob) instead of silently losing the
    /// event. The host always calls this; the in-memory unit tests do not.
    #[must_use]
    pub fn with_atomic_writes(
        mut self,
        pool: sqlx::PgPool,
        root: std::path::PathBuf,
        max_total_bytes: u64,
    ) -> Self {
        self.writer = Arc::new(PostgresFilesWriter::new(pool, root, max_total_bytes));
        self
    }

    async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
        authz::script_gate(
            &*self.authz,
            cx,
            Capability::AppFilesRead(cx.app_id),
            || FilesError::Forbidden,
            FilesError::Backend,
        )
        .await
    }

    async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
        authz::script_gate(
            &*self.authz,
            cx,
            Capability::AppFilesWrite(cx.app_id),
            || FilesError::Forbidden,
            FilesError::Backend,
        )
        .await
    }
}

/// Parse a script-supplied id. Invalid UUIDs aren't an error shape the
/// SDK exposes — for reads/deletes they simply mean "no such file".
fn parse_id(id: &str) -> Option<Uuid> {
    Uuid::parse_str(id).ok()
}

impl From<FilesRepoError> for FilesError {
    fn from(e: FilesRepoError) -> Self {
        match e {
            FilesRepoError::Corrupted => Self::Corrupted,
            FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
            other => Self::Backend(other.to_string()),
        }
    }
}

#[async_trait]
impl FilesService for FilesServiceImpl {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        mut new: NewFile,
    ) -> Result<Uuid, FilesError> {
        validate_files_collection(collection)?;
        self.check_write(cx).await?;
        new.validate(self.max_file_size_bytes)?;
        // Audit 2026-06-11 C-2 — coerce dangerous render types to
        // application/octet-stream after the shape checks pass.
        new.content_type = sanitize_stored_content_type(&new.content_type);
        self.writer.create(cx, collection, new).await
    }

    async fn head(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
    ) -> Result<Option<FileMeta>, FilesError> {
        validate_files_collection(collection)?;
        self.check_read(cx).await?;
        let Some(uuid) = parse_id(id) else {
            return Ok(None);
        };
        Ok(self.repo.head(cx.app_id, collection, uuid).await?)
    }

    async fn get(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
    ) -> Result<Option<Vec<u8>>, FilesError> {
        validate_files_collection(collection)?;
        self.check_read(cx).await?;
        let Some(uuid) = parse_id(id) else {
            return Ok(None);
        };
        Ok(self.repo.get(cx.app_id, collection, uuid).await?)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
        mut upd: FileUpdate,
    ) -> Result<(), FilesError> {
        validate_files_collection(collection)?;
        self.check_write(cx).await?;
        upd.validate(self.max_file_size_bytes)?;
        // Audit 2026-06-11 C-2 — sanitize after the shape checks pass.
        if let Some(ct) = upd.content_type.as_deref() {
            upd.content_type = Some(sanitize_stored_content_type(ct));
        }
        let Some(uuid) = parse_id(id) else {
            return Err(FilesError::NotFound);
        };
        if self.writer.update(cx, collection, uuid, upd).await? {
            Ok(())
        } else {
            Err(FilesError::NotFound)
        }
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
        validate_files_collection(collection)?;
        self.check_write(cx).await?;
        let Some(uuid) = parse_id(id) else {
            return Ok(false);
        };
        self.writer.delete(cx, collection, uuid).await
    }

    async fn list(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<FilesListPage, FilesError> {
        validate_files_collection(collection)?;
        self.check_read(cx).await?;
        Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
    }
}

// ----------------------------------------------------------------------------
// Tests — in-memory FilesRepo so unit tests need neither Postgres nor a
// filesystem. The on-disk atomic-write / checksum mechanics are covered
// by the tempdir tests in `files_repo.rs`.
// ----------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::authz::{AuthzError, AuthzRepo};
    use crate::files_repo::FileUpdated;
    use async_trait::async_trait;
    use chrono::Utc;
    use picloud_shared::{
        AdminUserId, AppId, AppRole, EmitError, ExecutionId, InstanceRole, Principal, RequestId,
        ScriptId, ServiceEvent, UserId,
    };
    use std::collections::BTreeMap;
    use std::sync::Mutex as StdMutex;
    use tokio::sync::Mutex;

    /// In-memory FilesRepo keyed by (app, collection, id). Stores the
    /// metadata + bytes together so cross-app isolation and round-trips
    /// can be checked without disk.
    #[derive(Default)]
    struct InMemoryFilesRepo {
        #[allow(clippy::type_complexity)]
        data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
    }

    fn sha256_hex(bytes: &[u8]) -> String {
        use sha2::{Digest, Sha256};
        let mut h = Sha256::new();
        h.update(bytes);
        let out = h.finalize();
        let mut s = String::new();
        for b in out {
            use std::fmt::Write as _;
            let _ = write!(s, "{b:02x}");
        }
        s
    }

    #[async_trait]
    impl FilesRepo for InMemoryFilesRepo {
        async fn create(
            &self,
            app_id: AppId,
            collection: &str,
            new: NewFile,
        ) -> Result<FileMeta, FilesRepoError> {
            let id = Uuid::new_v4();
            let now = 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: sha256_hex(&new.data),
                created_at: now,
                updated_at: now,
            };
            self.data.lock().await.insert(
                (app_id, collection.to_string(), id),
                (meta.clone(), new.data),
            );
            Ok(meta)
        }

        async fn head(
            &self,
            app_id: AppId,
            collection: &str,
            id: Uuid,
        ) -> Result<Option<FileMeta>, FilesRepoError> {
            Ok(self
                .data
                .lock()
                .await
                .get(&(app_id, collection.to_string(), id))
                .map(|(m, _)| m.clone()))
        }

        async fn get(
            &self,
            app_id: AppId,
            collection: &str,
            id: Uuid,
        ) -> Result<Option<Vec<u8>>, FilesRepoError> {
            Ok(self
                .data
                .lock()
                .await
                .get(&(app_id, collection.to_string(), id))
                .map(|(_, b)| b.clone()))
        }

        async fn update(
            &self,
            app_id: AppId,
            collection: &str,
            id: Uuid,
            upd: FileUpdate,
        ) -> Result<Option<FileUpdated>, FilesRepoError> {
            let mut data = self.data.lock().await;
            let key = (app_id, collection.to_string(), id);
            let Some((prev_meta, _)) = data.get(&key).cloned() else {
                return Ok(None);
            };
            let now = Utc::now();
            let new_meta = FileMeta {
                id,
                collection: collection.to_string(),
                name: upd.name.clone().unwrap_or_else(|| prev_meta.name.clone()),
                content_type: upd
                    .content_type
                    .clone()
                    .unwrap_or_else(|| prev_meta.content_type.clone()),
                size: upd.data.len() as u64,
                checksum: sha256_hex(&upd.data),
                created_at: prev_meta.created_at,
                updated_at: now,
            };
            data.insert(key, (new_meta.clone(), upd.data));
            Ok(Some(FileUpdated {
                new: new_meta,
                prev: prev_meta,
            }))
        }

        async fn delete(
            &self,
            app_id: AppId,
            collection: &str,
            id: Uuid,
        ) -> Result<Option<FileMeta>, FilesRepoError> {
            Ok(self
                .data
                .lock()
                .await
                .remove(&(app_id, collection.to_string(), id))
                .map(|(m, _)| m))
        }

        async fn list(
            &self,
            app_id: AppId,
            collection: &str,
            cursor: Option<&str>,
            limit: u32,
        ) -> Result<FilesListPage, FilesRepoError> {
            let data = self.data.lock().await;
            let after = cursor.and_then(|c| Uuid::parse_str(c).ok());
            let mut metas: Vec<FileMeta> = data
                .iter()
                .filter(|((a, c, _), _)| *a == app_id && c == collection)
                .map(|(_, (m, _))| m.clone())
                .filter(|m| after.is_none_or(|a| m.id > a))
                .collect();
            metas.sort_by_key(|m| m.id);
            let take = (limit.max(1)) as usize;
            let next_cursor = if metas.len() > take {
                metas.truncate(take);
                metas.last().map(|m| m.id.to_string())
            } else {
                None
            };
            Ok(FilesListPage {
                files: metas,
                next_cursor,
            })
        }
    }

    /// Captures emitted events so tests can assert on fan-out shape.
    #[derive(Default)]
    struct CapturingEmitter {
        events: StdMutex<Vec<ServiceEvent>>,
    }

    #[async_trait]
    impl ServiceEventEmitter for CapturingEmitter {
        async fn emit(&self, _cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
            self.events.lock().unwrap().push(event);
            Ok(())
        }
    }

    #[derive(Default)]
    struct DenyingAuthzRepo;
    #[async_trait]
    impl AuthzRepo for DenyingAuthzRepo {
        async fn membership(
            &self,
            _user_id: UserId,
            _app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(None)
        }
    }

    #[derive(Default)]
    struct EditorAuthzRepo;
    #[async_trait]
    impl AuthzRepo for EditorAuthzRepo {
        async fn membership(
            &self,
            _user_id: UserId,
            _app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(Some(AppRole::Editor))
        }
    }

    fn anon_cx(app_id: AppId) -> SdkCallCx {
        SdkCallCx {
            app_id,
            script_id: ScriptId::new(),
            principal: None,
            execution_id: ExecutionId::new(),
            request_id: RequestId::new(),
            trigger_depth: 0,
            root_execution_id: ExecutionId::new(),
            is_dead_letter_handler: false,
            event: None,
        }
    }

    fn member_cx(app_id: AppId) -> SdkCallCx {
        SdkCallCx {
            principal: Some(Principal {
                user_id: AdminUserId::new(),
                instance_role: InstanceRole::Member,
                scopes: None,
                app_binding: None,
            }),
            ..anon_cx(app_id)
        }
    }

    fn svc_with(authz: Arc<dyn AuthzRepo>, emitter: Arc<CapturingEmitter>) -> FilesServiceImpl {
        FilesServiceImpl::new(
            Arc::new(InMemoryFilesRepo::default()),
            authz,
            emitter,
            10 * 1024 * 1024,
        )
    }

    fn svc() -> FilesServiceImpl {
        svc_with(
            Arc::new(DenyingAuthzRepo),
            Arc::new(CapturingEmitter::default()),
        )
    }

    fn new_file(name: &str, data: &[u8]) -> NewFile {
        NewFile {
            name: name.to_string(),
            content_type: "application/octet-stream".to_string(),
            data: data.to_vec(),
        }
    }

    #[tokio::test]
    async fn dangerous_render_content_type_is_coerced_on_create() {
        // Audit 2026-06-11 C-2 closure.
        let files = svc();
        let cx = anon_cx(AppId::new());
        let id = files
            .create(
                &cx,
                "c",
                NewFile {
                    name: "evil.html".into(),
                    content_type: "text/html".into(),
                    data: b"<script>alert(1)</script>".to_vec(),
                },
            )
            .await
            .unwrap();
        let meta = files
            .head(&cx, "c", &id.to_string())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(meta.content_type, "application/octet-stream");
    }

    #[tokio::test]
    async fn dangerous_render_content_type_is_coerced_on_update() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let id = files
            .create(
                &cx,
                "c",
                NewFile {
                    name: "a.bin".into(),
                    content_type: "application/octet-stream".into(),
                    data: b"x".to_vec(),
                },
            )
            .await
            .unwrap();
        files
            .update(
                &cx,
                "c",
                &id.to_string(),
                FileUpdate {
                    data: b"y".to_vec(),
                    name: None,
                    content_type: Some("image/svg+xml".into()),
                },
            )
            .await
            .unwrap();
        let meta = files
            .head(&cx, "c", &id.to_string())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(meta.content_type, "application/octet-stream");
    }

    #[tokio::test]
    async fn create_then_get_head_round_trips() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let id = files
            .create(&cx, "avatars", new_file("a.bin", b"hello"))
            .await
            .unwrap();
        let bytes = files.get(&cx, "avatars", &id.to_string()).await.unwrap();
        assert_eq!(bytes, Some(b"hello".to_vec()));
        let meta = files
            .head(&cx, "avatars", &id.to_string())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(meta.name, "a.bin");
        assert_eq!(meta.size, 5);
        assert_eq!(meta.checksum, sha256_hex(b"hello"));
    }

    #[tokio::test]
    async fn get_and_head_missing_return_none() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let missing = Uuid::new_v4().to_string();
        assert_eq!(files.get(&cx, "c", &missing).await.unwrap(), None);
        assert!(files.head(&cx, "c", &missing).await.unwrap().is_none());
        // Non-UUID id is also "missing", not an error.
        assert_eq!(files.get(&cx, "c", "not-a-uuid").await.unwrap(), None);
    }

    #[tokio::test]
    async fn update_replaces_content_and_keeps_metadata_when_omitted() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let id = files
            .create(&cx, "c", new_file("v1.txt", b"one"))
            .await
            .unwrap();
        files
            .update(
                &cx,
                "c",
                &id.to_string(),
                FileUpdate {
                    data: b"two!!".to_vec(),
                    name: None,
                    content_type: None,
                },
            )
            .await
            .unwrap();
        let meta = files
            .head(&cx, "c", &id.to_string())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(meta.name, "v1.txt"); // kept
        assert_eq!(meta.size, 5);
        assert_eq!(
            files.get(&cx, "c", &id.to_string()).await.unwrap(),
            Some(b"two!!".to_vec())
        );
    }

    #[tokio::test]
    async fn update_missing_throws_not_found() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let err = files
            .update(
                &cx,
                "c",
                &Uuid::new_v4().to_string(),
                FileUpdate {
                    data: b"x".to_vec(),
                    name: None,
                    content_type: None,
                },
            )
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::NotFound));
    }

    #[tokio::test]
    async fn delete_returns_was_present() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let id = files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
        assert!(files.delete(&cx, "c", &id.to_string()).await.unwrap());
        assert!(!files.delete(&cx, "c", &id.to_string()).await.unwrap());
        assert!(!files.delete(&cx, "c", "not-a-uuid").await.unwrap());
    }

    #[tokio::test]
    async fn empty_collection_rejected() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let err = files
            .create(&cx, "", new_file("f", b"x"))
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::InvalidCollection(_)));
    }

    #[tokio::test]
    async fn traversal_collection_rejected() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        for bad in ["../etc", "a/b", "a..b", "x\0y"] {
            let err = files
                .create(&cx, bad, new_file("f", b"x"))
                .await
                .unwrap_err();
            assert!(
                matches!(err, FilesError::InvalidCollection(_)),
                "expected reject for {bad:?}"
            );
        }
    }

    #[tokio::test]
    async fn missing_required_fields_have_field_specific_messages() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        // name
        let err = files
            .create(
                &cx,
                "c",
                NewFile {
                    name: "  ".into(),
                    content_type: "text/plain".into(),
                    data: b"x".to_vec(),
                },
            )
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::MissingField("name")));
        // content_type
        let err = files
            .create(
                &cx,
                "c",
                NewFile {
                    name: "f".into(),
                    content_type: String::new(),
                    data: b"x".to_vec(),
                },
            )
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::MissingField("content_type")));
        // Empty `data` is NO LONGER rejected (v1.1.6 relaxation) — see
        // `empty_file_round_trips`.
    }

    #[tokio::test]
    async fn empty_file_round_trips() {
        // v1.1.6: a zero-byte blob is a valid stored state (sentinels,
        // placeholders). Create with empty data, then read it back.
        let files = svc();
        let cx = anon_cx(AppId::new());
        let id = files
            .create(
                &cx,
                "c",
                NewFile {
                    name: "empty.bin".into(),
                    content_type: "application/octet-stream".into(),
                    data: vec![],
                },
            )
            .await
            .expect("empty file create should succeed");
        let bytes = files.get(&cx, "c", &id.to_string()).await.unwrap();
        assert_eq!(bytes, Some(Vec::new()));
        let meta = files
            .head(&cx, "c", &id.to_string())
            .await
            .unwrap()
            .expect("metadata present");
        assert_eq!(meta.size, 0);
    }

    #[tokio::test]
    async fn name_and_content_type_length_caps_enforced() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        let long_name = "x".repeat(256);
        let err = files
            .create(&cx, "c", new_file(&long_name, b"x"))
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::NameTooLong(256)));

        let err = files
            .create(
                &cx,
                "c",
                NewFile {
                    name: "f".into(),
                    content_type: "x".repeat(128),
                    data: b"x".to_vec(),
                },
            )
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::ContentTypeTooLong(128)));
    }

    #[tokio::test]
    async fn per_file_size_cap_enforced() {
        let files = FilesServiceImpl::new(
            Arc::new(InMemoryFilesRepo::default()),
            Arc::new(DenyingAuthzRepo),
            Arc::new(CapturingEmitter::default()),
            8, // tiny cap
        );
        let cx = anon_cx(AppId::new());
        let err = files
            .create(&cx, "c", new_file("big", b"123456789"))
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::TooLarge { limit: 8, .. }));
    }

    #[tokio::test]
    async fn cross_app_isolation() {
        let files = svc();
        let app_a = AppId::new();
        let app_b = AppId::new();
        let cx_a = anon_cx(app_a);
        let cx_b = anon_cx(app_b);
        let id = files
            .create(&cx_a, "shared", new_file("f", b"from-a"))
            .await
            .unwrap();
        // app B cannot see app A's file by id.
        assert_eq!(
            files.get(&cx_b, "shared", &id.to_string()).await.unwrap(),
            None
        );
        assert!(files
            .head(&cx_b, "shared", &id.to_string())
            .await
            .unwrap()
            .is_none());
        let page_b = files.list(&cx_b, "shared", None, 100).await.unwrap();
        assert!(page_b.files.is_empty());
        // app A still sees it.
        assert!(files
            .get(&cx_a, "shared", &id.to_string())
            .await
            .unwrap()
            .is_some());
    }

    #[tokio::test]
    async fn anonymous_cx_skips_authz() {
        let files = svc(); // DenyingAuthzRepo
        let cx = anon_cx(AppId::new());
        // No principal → no authz check, even with a denying repo.
        files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
    }

    #[tokio::test]
    async fn member_without_role_is_forbidden() {
        let files = svc(); // DenyingAuthzRepo
        let cx = member_cx(AppId::new());
        let err = files
            .create(&cx, "c", new_file("f", b"x"))
            .await
            .unwrap_err();
        assert!(matches!(err, FilesError::Forbidden));
    }

    #[tokio::test]
    async fn member_with_editor_role_allowed() {
        let files = svc_with(
            Arc::new(EditorAuthzRepo),
            Arc::new(CapturingEmitter::default()),
        );
        let cx = member_cx(AppId::new());
        files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
    }

    #[tokio::test]
    async fn mutations_emit_events_with_correct_prev() {
        let emitter = Arc::new(CapturingEmitter::default());
        let files = svc_with(Arc::new(DenyingAuthzRepo), emitter.clone());
        let cx = anon_cx(AppId::new());

        let id = files.create(&cx, "c", new_file("f", b"one")).await.unwrap();
        files
            .update(
                &cx,
                "c",
                &id.to_string(),
                FileUpdate {
                    data: b"two".to_vec(),
                    name: None,
                    content_type: None,
                },
            )
            .await
            .unwrap();
        files.delete(&cx, "c", &id.to_string()).await.unwrap();

        let events = emitter.events.lock().unwrap();
        assert_eq!(events.len(), 3);
        // create: prev is None
        assert_eq!(events[0].op, "create");
        assert_eq!(events[0].source, "files");
        assert!(events[0].old_payload.is_none());
        assert!(events[0].payload.is_some());
        // update: prev is the prior metadata
        assert_eq!(events[1].op, "update");
        assert!(events[1].old_payload.is_some());
        // delete: prev is the deleted metadata (payload == old_payload)
        assert_eq!(events[2].op, "delete");
        assert_eq!(events[2].payload, events[2].old_payload);
        assert!(events[2].payload.is_some());
    }

    #[tokio::test]
    async fn list_cursor_paginates() {
        let files = svc();
        let cx = anon_cx(AppId::new());
        for i in 0..5 {
            files
                .create(&cx, "c", new_file(&format!("f{i}"), b"x"))
                .await
                .unwrap();
        }
        let p1 = files.list(&cx, "c", None, 2).await.unwrap();
        assert_eq!(p1.files.len(), 2);
        assert!(p1.next_cursor.is_some());
        let p2 = files
            .list(&cx, "c", p1.next_cursor.as_deref(), 2)
            .await
            .unwrap();
        assert_eq!(p2.files.len(), 2);
        let p3 = files
            .list(&cx, "c", p2.next_cursor.as_deref(), 2)
            .await
            .unwrap();
        assert_eq!(p3.files.len(), 1);
        assert!(p3.next_cursor.is_none());
    }
}
