//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection
//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts
//! reach via the `files::shared_collection("name")` Rhai handle (§11.6).
//!
//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`,
//! reads-open / writes-authed authz) with the files surface (collection
//! path-validation, field + size-cap validation, content-type sanitization). No
//! event emission in the MVP (the "group trigger has no app to watch" deferral).

use std::sync::Arc;

use async_trait::async_trait;
use picloud_shared::{
    sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
    GroupFilesError, GroupFilesService, GroupId, NewFile, NoopEventEmitter, SdkCallCx,
    ServiceEventEmitter,
};
use uuid::Uuid;

use crate::atomic_write::{
    BestEffortGroupFilesWriter, GroupFilesQuota, GroupFilesWriter, PostgresGroupFilesWriter,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError};

/// The registry `kind` this service resolves.
const KIND_FILES: &str = "files";

pub struct GroupFilesServiceImpl {
    repo: Arc<dyn GroupFilesRepo>,
    resolver: Arc<dyn GroupCollectionResolver>,
    authz: Arc<dyn AuthzRepo>,
    max_file_size_bytes: usize,
    /// §11.6 per-group quota: max total stored bytes across the group's
    /// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`).
    max_total_bytes: u64,
    /// §11.6: fires `shared = true` files triggers on a shared-collection write.
    /// Mutations: the byte-quota decision, the blob + metadata write, and the
    /// shared-trigger fan-out — for the host, all under one advisory-locked
    /// transaction. Reads stay on `repo`.
    writer: Arc<dyn GroupFilesWriter>,
    /// Set once [`Self::with_atomic_writes`] has installed the transactional
    /// writer. `with_events` refuses to overwrite it — silently downgrading a
    /// service back to non-transactional writes (and dropping its quota
    /// enforcement) because a builder call was appended in the wrong order is
    /// exactly the kind of regression the type system will not catch.
    atomic: bool,
}

impl GroupFilesServiceImpl {
    #[must_use]
    pub fn new(
        repo: Arc<dyn GroupFilesRepo>,
        resolver: Arc<dyn GroupCollectionResolver>,
        authz: Arc<dyn AuthzRepo>,
        max_file_size_bytes: usize,
    ) -> Self {
        Self {
            max_total_bytes: crate::quota::group_files_max_total_bytes_from_env(),
            writer: Arc::new(BestEffortGroupFilesWriter::new(
                repo.clone(),
                Arc::new(NoopEventEmitter),
            )),
            atomic: false,
            repo,
            resolver,
            authz,
            max_file_size_bytes,
        }
    }

    /// The ceiling a shared-files write must fit under.
    fn quota(&self) -> GroupFilesQuota {
        GroupFilesQuota {
            max_total_bytes: self.max_total_bytes,
        }
    }

    /// Wire the event emitter (§11.6 shared-collection triggers) on the
    /// best-effort writer. Superseded by [`Self::with_atomic_writes`].
    #[must_use]
    pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
        if self.atomic {
            tracing::warn!(
                "with_events() called after with_atomic_writes() — ignored; the \
                 transactional writer already resolves and writes the fan-out itself"
            );
            return self;
        }
        self.writer = Arc::new(BestEffortGroupFilesWriter::new(self.repo.clone(), events));
        self
    }

    /// Use the transactional writer: the byte-quota check, the metadata row, and
    /// the shared-trigger fan-out run on ONE connection under a per-group
    /// advisory lock, so concurrent uploads can't each see the same pre-write
    /// total and together overshoot the ceiling. Supersedes
    /// [`Self::with_events`].
    #[must_use]
    pub fn with_atomic_writes(mut self, pool: sqlx::PgPool, root: std::path::PathBuf) -> Self {
        self.writer = Arc::new(PostgresGroupFilesWriter::new(pool, root));
        self.atomic = true;
        self
    }

    async fn owning_group(
        &self,
        cx: &SdkCallCx,
        collection: &str,
    ) -> Result<GroupId, GroupFilesError> {
        self.resolver
            .resolve_owning_group(cx.app_id, collection, KIND_FILES)
            .await
            .map_err(|e| GroupFilesError::Backend(e.to_string()))?
            .ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string()))
    }

    async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
        authz::script_gate(
            &*self.authz,
            cx,
            Capability::GroupFilesRead(group_id),
            || GroupFilesError::Forbidden,
            GroupFilesError::Backend,
        )
        .await
    }

    async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
        // Fails closed for an anonymous principal — shared mutation always needs
        // an authenticated editor+ on the owning group.
        authz::script_gate_require_principal(
            &*self.authz,
            cx,
            Capability::GroupFilesWrite(group_id),
            || GroupFilesError::Forbidden,
            GroupFilesError::Backend,
        )
        .await
    }
}

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

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

#[async_trait]
impl GroupFilesService for GroupFilesServiceImpl {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        mut new: NewFile,
    ) -> Result<Uuid, GroupFilesError> {
        validate_files_collection(collection)?;
        let group_id = self.owning_group(cx, collection).await?;
        new.validate(self.max_file_size_bytes)?;
        // Coerce dangerous render types to application/octet-stream after the
        // shape checks pass (same as app files, audit 2026-06-11 C-2).
        new.content_type = sanitize_stored_content_type(&new.content_type);
        self.check_write(cx, group_id).await?;
        self.writer
            .create(cx, group_id, collection, new, self.quota())
            .await
    }

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

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

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: &str,
        mut upd: FileUpdate,
    ) -> Result<(), GroupFilesError> {
        validate_files_collection(collection)?;
        let group_id = self.owning_group(cx, collection).await?;
        upd.validate(self.max_file_size_bytes)?;
        if let Some(ct) = upd.content_type.as_deref() {
            upd.content_type = Some(sanitize_stored_content_type(ct));
        }
        self.check_write(cx, group_id).await?;
        let Some(uuid) = parse_id(id) else {
            return Err(GroupFilesError::NotFound);
        };
        if self
            .writer
            .update(cx, group_id, collection, uuid, upd, self.quota())
            .await?
        {
            Ok(())
        } else {
            Err(GroupFilesError::NotFound)
        }
    }

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

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

// ----------------------------------------------------------------------------
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres or
// 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 crate::group_files_repo::GroupFilesRepoError;
    use chrono::Utc;
    use picloud_shared::{
        AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
        UserId,
    };
    use std::collections::HashMap;
    use tokio::sync::Mutex;

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

    fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta {
        FileMeta {
            id,
            collection: collection.to_string(),
            name: new.name.clone(),
            content_type: new.content_type.clone(),
            size: new.data.len() as u64,
            checksum: String::new(),
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    #[async_trait]
    impl GroupFilesRepo for InMemoryGroupFilesRepo {
        async fn create(
            &self,
            group_id: GroupId,
            collection: &str,
            new: NewFile,
        ) -> Result<FileMeta, GroupFilesRepoError> {
            let id = Uuid::new_v4();
            let m = meta(id, collection, &new);
            self.data.lock().await.insert(
                (group_id, collection.to_string(), id),
                (m.clone(), new.data),
            );
            Ok(m)
        }

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

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

        async fn update(
            &self,
            group_id: GroupId,
            collection: &str,
            id: Uuid,
            upd: FileUpdate,
        ) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
            let mut data = self.data.lock().await;
            let key = (group_id, collection.to_string(), id);
            let Some((prev, _)) = data.get(&key).cloned() else {
                return Ok(None);
            };
            let mut m = prev.clone();
            m.size = upd.data.len() as u64;
            if let Some(n) = &upd.name {
                m.name = n.clone();
            }
            data.insert(key, (m.clone(), upd.data));
            Ok(Some(FileUpdated { new: m, prev }))
        }

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

        async fn list(
            &self,
            group_id: GroupId,
            collection: &str,
            _cursor: Option<&str>,
            _limit: u32,
        ) -> Result<FilesListPage, GroupFilesRepoError> {
            let files = self
                .data
                .lock()
                .await
                .iter()
                .filter(|((g, c, _), _)| *g == group_id && c == collection)
                .map(|(_, (m, _))| m.clone())
                .collect();
            Ok(FilesListPage {
                files,
                next_cursor: None,
            })
        }
    }

    #[derive(Default)]
    struct FakeResolver {
        map: HashMap<(AppId, String), GroupId>,
    }

    #[async_trait]
    impl GroupCollectionResolver for FakeResolver {
        async fn resolve_owning_group(
            &self,
            app_id: AppId,
            name: &str,
            _kind: &str,
        ) -> Result<Option<GroupId>, sqlx::Error> {
            Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
        }
    }

    #[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)
        }
    }

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

    fn owner() -> Principal {
        Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: None,
            app_binding: None,
        }
    }

    fn svc(resolver: FakeResolver) -> GroupFilesServiceImpl {
        GroupFilesServiceImpl::new(
            Arc::new(InMemoryGroupFilesRepo::default()),
            Arc::new(resolver),
            Arc::new(DenyingAuthzRepo),
            10 * 1024 * 1024,
        )
    }

    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 unrelated_app_gets_collection_not_shared() {
        let app_a = AppId::new();
        let app_b = AppId::new();
        let group = GroupId::new();
        let mut resolver = FakeResolver::default();
        resolver.map.insert((app_a, "assets".into()), group);
        let files = svc(resolver);

        let cx_a = cx_with(app_a, Some(owner()));
        let id = files
            .create(&cx_a, "assets", new_file("a.txt", b"hi"))
            .await
            .unwrap();
        assert!(files
            .get(&cx_a, "assets", &id.to_string())
            .await
            .unwrap()
            .is_some());

        let cx_b = cx_with(app_b, Some(owner()));
        let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err();
        assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets"));
    }

    #[tokio::test]
    async fn reads_open_writes_require_auth() {
        let app = AppId::new();
        let group = GroupId::new();
        let mut resolver = FakeResolver::default();
        resolver.map.insert((app, "assets".into()), group);
        let files = svc(resolver);

        let owner_cx = cx_with(app, Some(owner()));
        let id = files
            .create(&owner_cx, "assets", new_file("a.txt", b"hi"))
            .await
            .unwrap();

        // Anonymous READ (get) is allowed.
        let anon = cx_with(app, None);
        let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap();
        assert_eq!(bytes, Some(b"hi".to_vec()));

        // Anonymous WRITE (create) fails closed.
        let err = files
            .create(&anon, "assets", new_file("x.txt", b"x"))
            .await
            .unwrap_err();
        assert!(matches!(err, GroupFilesError::Forbidden));
    }

    #[tokio::test]
    async fn oversize_blob_rejected() {
        let app = AppId::new();
        let group = GroupId::new();
        let mut resolver = FakeResolver::default();
        resolver.map.insert((app, "assets".into()), group);
        let files = GroupFilesServiceImpl::new(
            Arc::new(InMemoryGroupFilesRepo::default()),
            Arc::new(resolver),
            Arc::new(DenyingAuthzRepo),
            8, // tiny cap
        );
        let cx = cx_with(app, Some(owner()));
        let err = files
            .create(&cx, "assets", new_file("big", b"123456789"))
            .await
            .unwrap_err();
        assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. }));
    }
}
