//! `GroupQueueServiceImpl` — wires `GroupQueueRepo` + the group-collection
//! registry underneath the `picloud_shared::GroupQueueService` trait that
//! scripts reach via the `queue::shared_collection("name")` Rhai handle
//! (§11.6 D3).
//!
//! Resolves the OWNING GROUP (kind `queue`) from `cx.app_id`'s chain (the
//! isolation boundary), enforces editor+ (`GroupQueueEnqueue`, fails closed on
//! anon — enqueue is a shared write), caps the payload size, then writes to the
//! group-keyed store. Consumption is out of band (competing per-descendant
//! materialized consumers, in the dispatcher).

use std::sync::Arc;

use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
    EnqueueOpts, GroupId, GroupQueueError, GroupQueueService, QueueMessageId, SdkCallCx,
};

use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_queue_repo::{GroupQueueRepo, NewGroupQueueMessage};
use crate::queue_service::queue_max_payload_bytes_from_env;

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

pub struct GroupQueueServiceImpl {
    repo: Arc<dyn GroupQueueRepo>,
    resolver: Arc<dyn GroupCollectionResolver>,
    authz: Arc<dyn AuthzRepo>,
    max_payload_bytes: usize,
}

impl GroupQueueServiceImpl {
    #[must_use]
    pub fn new(
        repo: Arc<dyn GroupQueueRepo>,
        resolver: Arc<dyn GroupCollectionResolver>,
        authz: Arc<dyn AuthzRepo>,
    ) -> Self {
        Self {
            repo,
            resolver,
            authz,
            max_payload_bytes: queue_max_payload_bytes_from_env(),
        }
    }

    async fn owning_group(
        &self,
        cx: &SdkCallCx,
        collection: &str,
    ) -> Result<GroupId, GroupQueueError> {
        if collection.is_empty() {
            return Err(GroupQueueError::InvalidCollection);
        }
        self.resolver
            .resolve_owning_group(cx.app_id, collection, KIND_QUEUE)
            .await
            .map_err(|e| GroupQueueError::Backend(e.to_string()))?
            .ok_or_else(|| GroupQueueError::CollectionNotShared(collection.to_string()))
    }

    async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupQueueError> {
        authz::script_gate_require_principal(
            &*self.authz,
            cx,
            Capability::GroupQueueEnqueue(group_id),
            || GroupQueueError::Forbidden,
            GroupQueueError::Backend,
        )
        .await
    }
}

#[async_trait]
impl GroupQueueService for GroupQueueServiceImpl {
    async fn enqueue(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        payload: serde_json::Value,
        opts: EnqueueOpts,
    ) -> Result<QueueMessageId, GroupQueueError> {
        if collection.is_empty() {
            return Err(GroupQueueError::InvalidCollection);
        }
        // Size cap first (no DB) — a cheap reject before resolve/authz.
        let encoded_len = serde_json::to_vec(&payload)
            .map(|v| v.len())
            .map_err(|e| GroupQueueError::Backend(format!("encode payload: {e}")))?;
        if encoded_len > self.max_payload_bytes {
            return Err(GroupQueueError::PayloadTooLarge {
                limit: self.max_payload_bytes,
                actual: encoded_len,
            });
        }
        let max_attempts = opts.max_attempts.unwrap_or(3);
        if !(1..=20).contains(&max_attempts) {
            return Err(GroupQueueError::InvalidOpts(
                "queue::enqueue: max_attempts must be in [1, 20]".into(),
            ));
        }
        if let Some(delay) = opts.delay_ms {
            if !(0..=86_400_000).contains(&delay) {
                return Err(GroupQueueError::InvalidOpts(
                    "queue::enqueue: delay_ms must be in [0, 86_400_000]".into(),
                ));
            }
        }
        let group_id = self.owning_group(cx, collection).await?;
        self.check_write(cx, group_id).await?;

        let deliver_after = opts
            .delay_ms
            .and_then(chrono::Duration::try_milliseconds)
            .map(|d| Utc::now() + d);
        self.repo
            .enqueue(NewGroupQueueMessage {
                group_id,
                collection: collection.to_string(),
                payload,
                deliver_after,
                max_attempts,
                enqueued_by_principal: cx.principal.as_ref().map(|p| p.user_id),
            })
            .await
            .map_err(|e| GroupQueueError::Backend(e.to_string()))
    }

    async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result<u64, GroupQueueError> {
        let group_id = self.owning_group(cx, collection).await?;
        // Depth is a read — reads are open (any subtree script).
        self.repo
            .depth(group_id, collection)
            .await
            .map_err(|e| GroupQueueError::Backend(e.to_string()))
    }

    async fn depth_pending(
        &self,
        cx: &SdkCallCx,
        collection: &str,
    ) -> Result<u64, GroupQueueError> {
        let group_id = self.owning_group(cx, collection).await?;
        self.repo
            .depth_pending(group_id, collection)
            .await
            .map_err(|e| GroupQueueError::Backend(e.to_string()))
    }
}

/// This service shipped with NO tests at all, while its three older siblings
/// (`group_kv_service`, `group_docs_service`, `group_files_service`) each pin the
/// same two invariants. Both matter more here than anywhere else: a shared-queue
/// consumer is materialized into EVERY descendant app, so an unauthorized enqueue
/// is attacker-controlled input fanned out as script execution across a whole
/// subtree.
///
/// The gap was concrete, not theoretical: swapping `script_gate_require_principal`
/// for `script_gate` (a one-token edit; the latter returns `Ok(())` for an
/// anonymous principal) would let an unauthenticated public HTTP route enqueue
/// into any ancestor group's shared queue — and nothing would have failed.
#[cfg(test)]
mod tests {
    use super::*;
    use crate::authz::AuthzError;
    use crate::group_queue_repo::{ClaimedGroupMessage, GroupQueueRepoError};
    use picloud_shared::{
        AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
        UserId,
    };
    use std::collections::HashMap;
    use uuid::Uuid;

    /// Only `enqueue` / `depth` / `depth_pending` are reachable from the service —
    /// consumption is the dispatcher's job — so the claim side is deliberately
    /// unreachable rather than faked.
    #[derive(Default)]
    struct RecordingRepo {
        enqueued: tokio::sync::Mutex<Vec<(GroupId, String)>>,
    }

    #[async_trait]
    impl GroupQueueRepo for RecordingRepo {
        async fn enqueue(
            &self,
            msg: NewGroupQueueMessage,
        ) -> Result<QueueMessageId, GroupQueueRepoError> {
            self.enqueued
                .lock()
                .await
                .push((msg.group_id, msg.collection));
            Ok(QueueMessageId::new())
        }
        async fn claim(
            &self,
            _: GroupId,
            _: &str,
        ) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError> {
            unreachable!("the service never consumes")
        }
        async fn ack(&self, _: QueueMessageId, _: Uuid) -> Result<bool, GroupQueueRepoError> {
            unreachable!("the service never consumes")
        }
        async fn nack(
            &self,
            _: QueueMessageId,
            _: Uuid,
            _: chrono::Duration,
        ) -> Result<bool, GroupQueueRepoError> {
            unreachable!("the service never consumes")
        }
        async fn release(
            &self,
            _: QueueMessageId,
            _: Uuid,
            _: chrono::Duration,
        ) -> Result<bool, GroupQueueRepoError> {
            unreachable!("the service never consumes")
        }
        #[allow(clippy::too_many_arguments)]
        async fn dead_letter(
            &self,
            _: QueueMessageId,
            _: Uuid,
            _: GroupId,
            _: &str,
            _: Option<picloud_shared::TriggerId>,
            _: Option<ScriptId>,
            _: u32,
            _: chrono::DateTime<Utc>,
            _: &str,
        ) -> Result<picloud_shared::DeadLetterId, GroupQueueRepoError> {
            unreachable!("the service never consumes")
        }
        async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError> {
            unreachable!("the service never consumes")
        }
        async fn depth(&self, _: GroupId, _: &str) -> Result<u64, GroupQueueRepoError> {
            Ok(7)
        }
        async fn depth_pending(&self, _: GroupId, _: &str) -> Result<u64, GroupQueueRepoError> {
            Ok(3)
        }
    }

    /// Models the ancestor-chain walk: a queue resolves only for apps whose chain
    /// declares it, and only under `kind = "queue"`. The REAL walk is pinned
    /// against Postgres in `tests/group_collection_isolation.rs`; this stands in
    /// for it so the service's own plumbing can be tested without a DB.
    #[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> {
            if kind != KIND_QUEUE {
                return Ok(None);
            }
            Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
        }
    }

    struct DenyingAuthzRepo;
    #[async_trait]
    impl AuthzRepo for DenyingAuthzRepo {
        async fn membership(&self, _: UserId, _: 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 member_no_role() -> Principal {
        Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Member,
            scopes: None,
            app_binding: None,
        }
    }

    fn svc(resolver: FakeResolver) -> GroupQueueServiceImpl {
        GroupQueueServiceImpl::new(
            Arc::new(RecordingRepo::default()),
            Arc::new(resolver),
            Arc::new(DenyingAuthzRepo),
        )
    }

    #[tokio::test]
    async fn unrelated_app_gets_collection_not_shared() {
        // app_a's chain declares "jobs"; app_b's does not.
        let (app_a, app_b, group) = (AppId::new(), AppId::new(), GroupId::new());
        let mut resolver = FakeResolver::default();
        resolver.map.insert((app_a, "jobs".into()), group);
        let q = svc(resolver);

        // Positive control: app_a really can enqueue and read the depth. Without
        // this, a service that rejected EVERYTHING would pass the negative below.
        let cx_a = cx_with(app_a, Some(owner()));
        q.enqueue(
            &cx_a,
            "jobs",
            serde_json::json!({"n": 1}),
            EnqueueOpts::default(),
        )
        .await
        .expect("an app on the chain can enqueue");
        assert_eq!(q.depth(&cx_a, "jobs").await.unwrap(), 7);

        // THE BOUNDARY: app_b is off-chain, so the name does not resolve — on the
        // write path and on the read path alike.
        let cx_b = cx_with(app_b, Some(owner()));
        let err = q
            .enqueue(&cx_b, "jobs", serde_json::json!({}), EnqueueOpts::default())
            .await
            .unwrap_err();
        assert!(
            matches!(&err, GroupQueueError::CollectionNotShared(c) if c == "jobs"),
            "a foreign app must not reach another subtree's shared queue, got {err:?}"
        );
        let err = q.depth(&cx_b, "jobs").await.unwrap_err();
        assert!(matches!(&err, GroupQueueError::CollectionNotShared(c) if c == "jobs"));
    }

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

        // Reads are open — the declaration IS the grant, anonymous included.
        let anon = cx_with(app, None);
        assert_eq!(
            q.depth(&anon, "jobs").await.unwrap(),
            7,
            "depth is a read; reads are open to any subtree script"
        );

        // Writes fail closed. This is the assertion that fires if
        // `script_gate_require_principal` is ever swapped for `script_gate`.
        let err = q
            .enqueue(&anon, "jobs", serde_json::json!({}), EnqueueOpts::default())
            .await
            .unwrap_err();
        assert!(
            matches!(err, GroupQueueError::Forbidden),
            "an ANONYMOUS enqueue must fail closed — a public route must not be able to \
             inject work that every descendant app's consumer will execute; got {err:?}"
        );

        // Authenticated but without an editor+ role on the owning group: also denied.
        let member = cx_with(app, Some(member_no_role()));
        let err = q
            .enqueue(
                &member,
                "jobs",
                serde_json::json!({}),
                EnqueueOpts::default(),
            )
            .await
            .unwrap_err();
        assert!(
            matches!(err, GroupQueueError::Forbidden),
            "authentication alone is not authorization — editor+ on the owning group is required"
        );
    }
}
