//! `GroupPubsubServiceImpl` — wires the group-collection registry + the pubsub
//! fan-out repo underneath the `picloud_shared::GroupPubsubService` trait that
//! scripts reach via the `pubsub::shared_topic("name")` Rhai handle (§11.6 D2).
//!
//! A shared topic is storeless — a publish resolves the OWNING GROUP (the
//! nearest ancestor group declaring the topic shared, kind `topic`), then fans
//! out to that group's `shared = true` pubsub triggers, each delivery stamped
//! with the WRITER `cx.app_id` so the handler runs under the publishing app
//! (matching the M2 shared-collection trigger model). Owner resolution is the
//! isolation boundary — a foreign app's chain never reaches the owning group.
//!
//! Trust model: a publish is a WRITE, so it uses `script_gate_require_principal`
//! (anonymous fails closed — shared publish always needs an authenticated
//! editor+ on the owning group).

use std::sync::Arc;

use async_trait::async_trait;
use picloud_shared::{
    GroupId, GroupPubsubError, GroupPubsubService, RealtimeBroadcaster, RealtimeEvent, SdkCallCx,
    TriggerEvent,
};

use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::pubsub_repo::{PublishCtx, PubsubRepo};
use crate::pubsub_service::pubsub_max_message_bytes_from_env;

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

pub struct GroupPubsubServiceImpl {
    repo: Arc<dyn PubsubRepo>,
    resolver: Arc<dyn GroupCollectionResolver>,
    authz: Arc<dyn AuthzRepo>,
    max_message_bytes: usize,
    /// §11.6 D2: the realtime broadcaster, attached by the host via
    /// [`Self::with_realtime`]. `None` in test bundles → no external SSE fan-out
    /// (durable trigger fan-out is unaffected).
    realtime: Option<Arc<dyn RealtimeBroadcaster>>,
}

impl GroupPubsubServiceImpl {
    #[must_use]
    pub fn new(
        repo: Arc<dyn PubsubRepo>,
        resolver: Arc<dyn GroupCollectionResolver>,
        authz: Arc<dyn AuthzRepo>,
    ) -> Self {
        Self {
            repo,
            resolver,
            authz,
            max_message_bytes: pubsub_max_message_bytes_from_env(),
            realtime: None,
        }
    }

    /// §11.6 D2: attach the realtime broadcaster so a shared-topic publish also
    /// fans out to external SSE subscribers (in addition to the durable
    /// `shared = true` trigger fan-out). Mirrors `PubsubServiceImpl::with_realtime`.
    #[must_use]
    pub fn with_realtime(mut self, broadcaster: Arc<dyn RealtimeBroadcaster>) -> Self {
        self.realtime = Some(broadcaster);
        self
    }

    /// The structural boundary: resolve the topic namespace to its owning group
    /// on the calling app's chain. `CollectionNotShared` when no ancestor group
    /// declares it — the same outcome a foreign app gets, by construction.
    async fn owning_group(
        &self,
        cx: &SdkCallCx,
        namespace: &str,
    ) -> Result<GroupId, GroupPubsubError> {
        self.resolver
            .resolve_owning_group(cx.app_id, namespace, KIND_TOPIC)
            .await
            .map_err(|e| GroupPubsubError::Backend(e.to_string()))?
            .ok_or_else(|| GroupPubsubError::CollectionNotShared(namespace.to_string()))
    }

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

#[async_trait]
impl GroupPubsubService for GroupPubsubServiceImpl {
    async fn publish(
        &self,
        cx: &SdkCallCx,
        namespace: &str,
        subtopic: &str,
        message: serde_json::Value,
    ) -> Result<u32, GroupPubsubError> {
        if namespace.trim().is_empty() {
            return Err(GroupPubsubError::InvalidTopic);
        }
        // Size cap first (no DB) — a cheap reject before the resolve/authz work.
        let encoded_len = serde_json::to_vec(&message)
            .map(|v| v.len())
            .map_err(|e| GroupPubsubError::Backend(format!("encode message: {e}")))?;
        if encoded_len > self.max_message_bytes {
            return Err(GroupPubsubError::MessageTooLarge {
                limit: self.max_message_bytes,
                actual: encoded_len,
            });
        }
        let group_id = self.owning_group(cx, namespace).await?;
        self.check_write(cx, group_id).await?;

        // The full topic is `namespace` or `namespace.subtopic`; shared triggers
        // match it with the same `topic_matches` semantics as the per-app path.
        let topic = if subtopic.trim().is_empty() {
            namespace.to_string()
        } else {
            format!("{namespace}.{subtopic}")
        };
        let published_at = chrono::Utc::now();
        let event = TriggerEvent::Pubsub {
            topic: topic.clone(),
            message: message.clone(),
            published_at,
        };
        let payload = serde_json::to_value(&event)
            .map_err(|e| GroupPubsubError::Backend(format!("event serialize: {e}")))?;
        let publish_ctx = PublishCtx {
            app_id: cx.app_id,
            origin_principal: cx.principal.as_ref().map(|p| p.user_id),
            trigger_depth: cx.trigger_depth,
            root_execution_id: cx.root_execution_id,
        };
        // Durable trigger fan-out FIRST (the authoritative delivery path).
        let count = self
            .repo
            .fan_out_shared_publish(publish_ctx, group_id, &topic, payload)
            .await
            .map_err(|e| GroupPubsubError::Backend(e.to_string()))?;
        // Then the best-effort realtime broadcast to external SSE subscribers,
        // keyed by the OWNING GROUP so every subtree subscriber shares one
        // channel. A broadcast failure never fails the publish.
        if let Some(realtime) = &self.realtime {
            realtime
                .publish_group(
                    group_id,
                    &topic,
                    RealtimeEvent {
                        topic: topic.clone(),
                        message,
                        published_at,
                    },
                )
                .await;
        }
        Ok(count)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use picloud_shared::{
        AdminUserId, AppId, BroadcasterError, ExecutionId, InstanceRole, Principal,
        RealtimeBroadcaster, RealtimeEvent, RequestId, ScriptId,
    };
    use std::sync::Mutex;
    use tokio::sync::broadcast;

    use crate::authz::AuthzError;
    use crate::pubsub_repo::PubsubRepoError;

    /// Records `publish_group` calls so the bridge can be asserted.
    #[derive(Default)]
    struct RecordingBroadcaster {
        group_publishes: Mutex<Vec<(GroupId, String, serde_json::Value)>>,
    }
    #[async_trait]
    impl RealtimeBroadcaster for RecordingBroadcaster {
        async fn subscribe(
            &self,
            _: picloud_shared::AppId,
            _: &str,
        ) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
            Ok(broadcast::channel(1).1)
        }
        async fn publish(&self, _: picloud_shared::AppId, _: &str, _: RealtimeEvent) {}
        async fn drop_topic(&self, _: picloud_shared::AppId, _: &str) {}
        async fn publish_group(&self, group_id: GroupId, topic: &str, event: RealtimeEvent) {
            self.group_publishes
                .lock()
                .unwrap()
                .push((group_id, topic.to_string(), event.message));
        }
    }

    struct FakeRepo;
    #[async_trait]
    impl PubsubRepo for FakeRepo {
        async fn fan_out_publish(
            &self,
            _: PublishCtx,
            _: &str,
            _: serde_json::Value,
        ) -> Result<u32, PubsubRepoError> {
            Ok(0)
        }
        async fn fan_out_shared_publish(
            &self,
            _: PublishCtx,
            _: GroupId,
            _: &str,
            _: serde_json::Value,
        ) -> Result<u32, PubsubRepoError> {
            Ok(2) // pretend two shared triggers matched
        }
    }

    struct FixedResolver(GroupId);
    #[async_trait]
    impl GroupCollectionResolver for FixedResolver {
        async fn resolve_owning_group(
            &self,
            _: AppId,
            _: &str,
            kind: &str,
        ) -> Result<Option<GroupId>, sqlx::Error> {
            Ok((kind == KIND_TOPIC).then_some(self.0))
        }
    }

    struct DenyAuthz;
    #[async_trait]
    impl AuthzRepo for DenyAuthz {
        async fn membership(
            &self,
            _: picloud_shared::UserId,
            _: AppId,
        ) -> Result<Option<picloud_shared::AppRole>, AuthzError> {
            Ok(None)
        }
    }

    fn owner_cx(app_id: AppId) -> SdkCallCx {
        SdkCallCx {
            app_id,
            script_id: ScriptId::new(),
            // Instance Owner bypasses the capability check (as in the group_kv
            // tests), so DenyAuthz is fine — we're testing the broadcast bridge.
            principal: Some(Principal {
                user_id: AdminUserId::new(),
                instance_role: InstanceRole::Owner,
                scopes: None,
                app_binding: None,
            }),
            execution_id: ExecutionId::new(),
            request_id: RequestId::new(),
            trigger_depth: 0,
            root_execution_id: ExecutionId::new(),
            is_dead_letter_handler: false,
            event: None,
        }
    }

    #[tokio::test]
    async fn publish_bridges_to_the_group_broadcaster() {
        // §11.6 D2: a shared-topic publish fans out to the durable triggers AND,
        // when a broadcaster is attached, to the OWNING GROUP's realtime channel
        // with the full topic (`namespace.subtopic`).
        let group = GroupId::new();
        let app = AppId::new();
        let recorder = Arc::new(RecordingBroadcaster::default());
        let svc = GroupPubsubServiceImpl::new(
            Arc::new(FakeRepo),
            Arc::new(FixedResolver(group)),
            Arc::new(DenyAuthz),
        )
        .with_realtime(recorder.clone());

        let count = svc
            .publish(
                &owner_cx(app),
                "events",
                "created",
                serde_json::json!({ "id": 1 }),
            )
            .await
            .unwrap();
        assert_eq!(count, 2, "durable fan-out count is returned");

        let calls = recorder.group_publishes.lock().unwrap();
        assert_eq!(calls.len(), 1, "one realtime broadcast");
        assert_eq!(calls[0].0, group, "keyed by the owning group");
        assert_eq!(calls[0].1, "events.created", "full topic");
        assert_eq!(calls[0].2, serde_json::json!({ "id": 1 }));
    }

    #[tokio::test]
    async fn publish_without_broadcaster_still_fans_out() {
        // No broadcaster attached → durable fan-out only, no panic.
        let group = GroupId::new();
        let svc = GroupPubsubServiceImpl::new(
            Arc::new(FakeRepo),
            Arc::new(FixedResolver(group)),
            Arc::new(DenyAuthz),
        );
        let count = svc
            .publish(&owner_cx(AppId::new()), "events", "", serde_json::json!({}))
            .await
            .unwrap();
        assert_eq!(count, 2);
    }

    // ------------------------------------------------------------------
    // The two invariants the three older sibling services pin, and this one
    // did not. Both existing tests above use `FixedResolver` (always resolves)
    // and `owner_cx` (an instance Owner, who bypasses the capability check) —
    // so neither the isolation boundary nor the anon gate was exercised at all.
    // ------------------------------------------------------------------

    /// Resolves a topic only for apps whose chain declares it — the fake stand-in
    /// for the ancestor walk. The real walk is pinned against Postgres in
    /// `tests/group_collection_isolation.rs`.
    #[derive(Default)]
    struct ChainResolver {
        map: std::collections::HashMap<(AppId, String), GroupId>,
    }
    #[async_trait]
    impl GroupCollectionResolver for ChainResolver {
        async fn resolve_owning_group(
            &self,
            app_id: AppId,
            name: &str,
            kind: &str,
        ) -> Result<Option<GroupId>, sqlx::Error> {
            if kind != KIND_TOPIC {
                return Ok(None);
            }
            Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
        }
    }

    fn cx(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 member_no_role() -> Principal {
        Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Member,
            scopes: None,
            app_binding: None,
        }
    }

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

    #[tokio::test]
    async fn unrelated_app_gets_collection_not_shared() {
        let (app_a, app_b, group) = (AppId::new(), AppId::new(), GroupId::new());
        let mut resolver = ChainResolver::default();
        resolver.map.insert((app_a, "events".into()), group);
        let svc = GroupPubsubServiceImpl::new(
            Arc::new(FakeRepo),
            Arc::new(resolver),
            Arc::new(DenyAuthz),
        );

        // Positive control — app_a, on the chain, publishes fine.
        svc.publish(&owner_cx(app_a), "events", "created", serde_json::json!({}))
            .await
            .expect("an app on the chain can publish");

        // THE BOUNDARY — app_b is in another subtree.
        let err = svc
            .publish(
                &cx(app_b, Some(owner_principal())),
                "events",
                "created",
                serde_json::json!({}),
            )
            .await
            .unwrap_err();
        assert!(
            matches!(&err, GroupPubsubError::CollectionNotShared(t) if t == "events"),
            "a foreign app must not publish into another subtree's shared topic, got {err:?}"
        );
    }

    #[tokio::test]
    async fn publish_fails_closed_for_anon() {
        let (app, group) = (AppId::new(), GroupId::new());
        let mut resolver = ChainResolver::default();
        resolver.map.insert((app, "events".into()), group);
        let svc = GroupPubsubServiceImpl::new(
            Arc::new(FakeRepo),
            Arc::new(resolver),
            Arc::new(DenyAuthz),
        );

        // A shared publish fans out to every `shared = true` trigger on the group,
        // each handler running under the writer's app_id. An anonymous public route
        // must not be able to drive that. This is the assertion that fires if
        // `script_gate_require_principal` is swapped for `script_gate`.
        let err = svc
            .publish(&cx(app, None), "events", "created", serde_json::json!({}))
            .await
            .unwrap_err();
        assert!(
            matches!(err, GroupPubsubError::Forbidden),
            "an ANONYMOUS shared publish must fail closed, got {err:?}"
        );

        // Authenticated but with no editor+ on the owning group: also denied.
        let err = svc
            .publish(
                &cx(app, Some(member_no_role())),
                "events",
                "created",
                serde_json::json!({}),
            )
            .await
            .unwrap_err();
        assert!(
            matches!(err, GroupPubsubError::Forbidden),
            "authentication alone is not authorization — editor+ on the owning group is required"
        );
    }
}
