#!/usr/bin/env python3

# Libervia Pubsub Component
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from dataclasses import dataclass, field, fields
from typing import Literal, cast

import shortuuid
from twisted.internet import defer
from twisted.words.protocols.jabber import error, jid
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
from tx_xmpp import disco, itx_xmpp
from tx_xmpp import pubsub, rsm
from tx_xmpp.pubsub import NS_PUBSUB_EVENT
from zope.interface import implementer

from libervia.backend import G
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPComponent, SatXMPPEntity
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.memory.sqla_mapping import (
    AccessModel,
    Affiliation,
    PublishModel,
    PubsubAffiliation,
    PubsubNode,
    PubsubSub,
    SubscriptionState,
)
from libervia.backend.plugins.plugin_xep_0060 import NodeMetadata, XEP_0060
from libervia.backend.plugins.plugin_xep_0167 import NS_AV_CONFERENCES
from libervia.backend.plugins.plugin_xep_0355 import XEP_0355, DelegationData
from libervia.backend.plugins.plugin_xep_0356 import XEP_0356
from libervia.backend.plugins.plugin_xep_0496 import LINK_VAR, PARENT_VAR, XEP_0496
from libervia.backend.plugins.plugin_xep_0499 import (
    DiscoPubsubItem,
    DiscoPubsubNode,
    ExtDiscoOptions,
    ExtDiscoResult,
    XEP_0499,
)
from libervia.backend.tools.config import ComponentSettings
from libervia.backend.tools.xml_tools import element_copy

log = getLogger(__name__)

IMPORT_NAME = "pubsub"
NAME = "Libervia Pubsub"

PLUGIN_INFO = {
    C.PI_NAME: NAME,
    C.PI_IMPORT_NAME: IMPORT_NAME,
    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
    C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
    C.PI_PROTOCOLS: [],
    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0355", "XEP-0356", "XEP-0496", "XEP-0499"],
    C.PI_RECOMMENDATIONS: [],
    C.PI_MAIN: "PubsubComponent",
    C.PI_HANDLER: C.BOOL_TRUE,
    C.PI_DESCRIPTION: _("Libervia advanced Pubsub service."),
}

NotificationEventType = Literal["items", "retract", "delete", "purge", "configuration"]


@dataclass
class PubsubNodeConfig:
    max_items: int | Literal["max"] = field(
        default="max", metadata={"label": "Maximum number of items to persist"}
    )
    access_model: AccessModel = field(
        default=AccessModel.open,
        metadata={"label": "Who may subscribe and retrieve items"},
    )
    publish_model: PublishModel = field(
        default=PublishModel.publishers, metadata={"label": "The publisher model"}
    )
    title: str | None = field(
        default=None, metadata={"label": "A short name for the node"}
    )
    description: str | None = field(
        default=None, metadata={"label": "A description of the node"}
    )
    parent: str | None = field(default=None, metadata={"label": "Parent node identifier"})
    link: str | None = field(default=None, metadata={"label": "Linked node identifier"})

    _field_var_mapping = {
        "access_model": "pubsub#access_model",
        "publish_model": "pubsub#publish_model",
        "max_items": "pubsub#max_items",
        "title": "pubsub#title",
        "description": "pubsub#description",
        "parent": PARENT_VAR,
        "link": LINK_VAR,
    }

    @classmethod
    def get_field_definitions(cls) -> dict[str, dict]:
        """Return field definitions for Tx-XMPP configuration forms.

        This method dynamically builds the schema by introspecting dataclass fields
        and their metadata.

        @return: A dict mapping pubsub field vars to their definitions.
        """
        definitions: dict = {}

        for field_ in fields(cls):
            field_name = field_.name

            try:
                pubsub_var = cls._field_var_mapping[field_name]
            except KeyError:
                log.error("Missing field mapping for {field_name!r}, ignoring.")
                continue

            label = field_.metadata.get("label", pubsub_var)

            match field_name:
                case "access_model" | "publish_model":
                    enum_type = (
                        AccessModel if field_name == "access_model" else PublishModel
                    )
                    definitions[pubsub_var] = {
                        "type": "list-single",
                        "label": label,
                        "options": {value.value: None for value in enum_type},
                    }
                case "max_items":
                    definitions[pubsub_var] = {
                        "type": "text-single",
                        "label": label,
                    }
                case _:
                    definitions[pubsub_var] = {
                        "type": "text-single",
                        "label": label,
                    }

        return definitions


class PubsubSettings(ComponentSettings, plugin_info=PLUGIN_INFO):
    pass


class PubsubResource(pubsub.PubSubResource):
    """PubSub resource implementation for Libervia Backend.

    This resource handles all pubsub operations including node creation,
    item publishing, subscriptions, affiliations, and node management.
    """

    features: list[str] = [
        "config-node",
        "create-nodes",
        "delete-nodes",
        "item-ids",
        "meta-data",
        "multi-items",
        "publish",
        "purge-nodes",
        "retract-items",
        "retrieve-affiliations",
        "retrieve-default",
        "retrieve-items",
        "retrieve-subscriptions",
        "subscribe",
    ]

    def __init__(self, service: "PubsubService") -> None:
        super().__init__()
        self.service = service
        self.xep_0355 = self.service.component.xep_0355
        self.xep_0356 = cast(XEP_0356, G.host.plugins["XEP-0356"])
        self.xep_0496 = cast(XEP_0496, G.host.plugins["XEP-0496"])

        G.host.trigger.add("XEP-0356_presence_received", self._on_presence_received)

    @property
    def client(self) -> SatXMPPComponent:
        return self.service.client

    def getInfo(
        self,
        requestor: jid.JID,
        service: jid.JID,
        nodeIdentifier: str,
    ) -> defer.Deferred[dict | None]:
        return defer.ensureDeferred(self.get_info(requestor, service, nodeIdentifier))

    async def get_info(
        self,
        requestor: jid.JID,
        service: jid.JID,
        nodeIdentifier: str,
    ) -> dict | None:
        """Get node information for disco#info.

        @param requestor: The entity the request originated from.
        @param service: The publish-subscribe service entity.
        @param nodeIdentifier: Identifier of the node to get info for.
        @return: Node type and metadata, as expected by ``Tx-XMPP``.
        """
        node = await G.storage.get_pubsub_node(self.client, service, nodeIdentifier)

        if node is None:
            return None

        metadata = NodeMetadata(
            access_model=str(node.access_model) if node.access_model else None,
            publish_model=str(node.publish_model) if node.publish_model else None,
        )

        # We convert to the format expected by Tx-XMPP.
        form = metadata.to_data_form()
        metadata_list = [
            {
                "var": field.var,
                "type": field.fieldType,
                "value": field.value,
                "label": field.label,
            }
            for field in form.fieldList
        ]

        return {
            "type": "leaf",
            "meta-data": metadata_list if metadata_list else None,
        }

    def getNodes(
        self, requestor: jid.JID, service: jid.JID, nodeIdentifier: str
    ) -> defer.Deferred[list[str]]:
        return defer.ensureDeferred(self._get_nodes(requestor, service, nodeIdentifier))

    async def _get_nodes(
        self, requestor: jid.JID, service: jid.JID, node_id: str
    ) -> list[str]:
        """Get list of child nodes for a given node (async implementation).

        @param requestor: The entity the request originated from.
        @param service: The publish-subscribe service entity.
        @param node_id: Identifier of the node to request the children for.
        @return: A list of node identifiers.
        """
        if node_id:
            # We only return results for requests without a specific node
            return []

        nodes = await G.storage.get_pubsub_nodes(self.client, service)
        return [cast(str, node.name) for node in nodes]

    def getConfigurationOptions(self) -> dict[str, dict]:
        """Return the available configuration options for pubsub nodes.

        @return: A map field vars to their definition in the format expected by Tx-XMPP.
        """
        return PubsubNodeConfig.get_field_definitions()

    def publish(
        self, request: pubsub.PubSubRequest
    ) -> defer.Deferred[None | domish.Element]:
        return defer.ensureDeferred(self._publish(request))

    async def _publish(self, request: pubsub.PubSubRequest) -> None | domish.Element:
        """Publish items to a node.

        @param request: The publish request.
        @return: A pubsub element containing the published items.
        @raise StanzaError: with "bad-request" if no items are provided
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor lacks publish privileges
        @raise StanzaError: with "not-allowed" if item format is invalid
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        if request.items is None or len(request.items) == 0:
            raise error.StanzaError("bad-request", text="no item to publish")

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
            with_subscriptions=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_publish_rights = await self.xep_0496.has_publish_rights(
            self.client, service_jid, node, requestor_jid
        )
        if not has_publish_rights:
            raise error.StanzaError("forbidden", text="Insufficient privileges")

        ret_pubsub_elt = domish.Element((NS_PUBSUB_EVENT, "pubsub"))
        ret_publish_elt = ret_pubsub_elt.addElement("publish")
        ret_publish_elt["node"] = request.nodeIdentifier

        items_data = []
        for item in request.items:
            if item.name != "item" or item.uri != pubsub.NS_PUBSUB:
                raise error.StanzaError(
                    "not-allowed", text=f"Invalid item: {item.toXml()}"
                )

            item["publisher"] = request.sender.full()

            if not item.getAttribute("id"):
                item["id"] = f"{shortuuid.uuid()}"

            ret_item_elt = ret_publish_elt.addElement("item")
            ret_item_elt["id"] = item["id"]

            items_data.append(item)

        await G.storage.cache_pubsub_items(client, node, items_data)

        await self._send_notifications(node, "items", items_data)

        return ret_pubsub_elt

    def subscribe(
        self, request: pubsub.PubSubRequest
    ) -> defer.Deferred[pubsub.Subscription]:
        return defer.ensureDeferred(self._subscribe(request))

    async def _subscribe(self, request: pubsub.PubSubRequest) -> pubsub.Subscription:
        """Handle a subscribe request.

        @param request: The subscribe request containing node identifier, subscriber JID,
            and optional subscription options.
        @return: A Subscription object representing the resulting subscription state.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor lacks subscribe privileges
        @raise StanzaError: with "not-authorized" if there's a pending subscription
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        assert request.subscriber is not None
        subscriber_jid = request.subscriber.userhostJID()
        service_jid = request.recipient if request.recipient else client.jid

        if subscriber_jid != requestor_jid:
            raise error.StanzaError(
                "forbidden", text="Cannot subscribe on behalf of another entity"
            )

        node = await G.storage.get_pubsub_node(
            client, service_jid, request.nodeIdentifier, with_affiliations=True
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_access = await self.xep_0496.has_access(
            self.client, service_jid, node, subscriber_jid
        )
        if not has_access:
            raise error.StanzaError(
                "forbidden", text="Insufficient privileges to subscribe to this node"
            )

        affiliation = next(
            (
                aff_data.affiliation
                for aff_data in node.affiliations
                if aff_data.entity == subscriber_jid
            ),
            None,
        )

        if affiliation == Affiliation.outcast:
            raise error.StanzaError("forbidden", text="You can't subscribe this node")

        access_model = node.access_model or AccessModel.open

        if access_model == AccessModel.open:
            pass
        elif access_model == AccessModel.whitelist:
            if affiliation not in (
                Affiliation.owner,
                Affiliation.publisher,
                Affiliation.member,
            ):
                raise error.StanzaError(
                    "not-authorized", text="You can't subscribe to this node"
                )
        else:
            log.error(
                f"Unknown access model {access_model!r} for node"
                f" {request.nodeIdentifier!r}"
            )
            raise error.StanzaError(
                "internal-server-error",
                text=f"Unknown access model {access_model!r}",
            )

        # Check for existing pending subscription
        existing_subs = await G.storage.get_pubsub_subscriptions(
            client,
            node=node,
            entity=subscriber_jid,
        )
        existing_sub = existing_subs[0] if existing_subs else None

        if existing_sub and existing_sub.state == SubscriptionState.PENDING:
            raise error.StanzaError(
                "not-authorized", text="You already have a pending subscription"
            )

        # For now, we auto-approve subscriptions
        # TODO: Implement pending subscription approval workflow
        await G.storage.set_pubsub_subscriptions(
            node,
            {subscriber_jid: SubscriptionState.SUBSCRIBED},
        )

        return pubsub.Subscription(
            nodeIdentifier=request.nodeIdentifier,
            subscriber=request.subscriber,
            state="subscribed",
        )

    def unsubscribe(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._unsubscribe(request))

    async def _unsubscribe(self, request: pubsub.PubSubRequest) -> None:
        """Handle an unsubscribe request.

        @param request: The unsubscribe request
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not the subscriber
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        assert request.subscriber is not None
        subscriber_jid = request.subscriber.userhostJID()
        service_jid = request.recipient if request.recipient else client.jid

        # Check that the requestor matches the subscriber
        if subscriber_jid != requestor_jid:
            raise error.StanzaError(
                "forbidden", text="Cannot unsubscribe on behalf of another entity"
            )

        node = await G.storage.get_pubsub_node(
            client, service_jid, request.nodeIdentifier
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        await G.storage.set_pubsub_subscriptions(
            node,
            {subscriber_jid: None},
        )

    def subscriptions(
        self, request: pubsub.PubSubRequest
    ) -> defer.Deferred[list[pubsub.Subscription]]:
        return defer.ensureDeferred(self._subscriptions(request))

    async def _subscriptions(
        self, request: pubsub.PubSubRequest
    ) -> list[pubsub.Subscription]:
        """Retrieve the subscriptions of the requestor for one or all nodes.

        @param request: The subscriptions request.
            If nodeIdentifier is set, return subscription for that specific node.
            If nodeIdentifier is None, return subscriptions for all nodes.
        @return: A list of Subscription objects.
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()
        service_jid = request.recipient if request.recipient else client.jid

        subscriptions_data = await G.storage.get_pubsub_subscriptions(
            client,
            service=service_jid,
            entity=requestor_jid,
            node_name=request.nodeIdentifier,
        )

        # Convert to Subscription objects
        subscriptions = []
        for pubsub_sub in subscriptions_data:
            state_str = (
                "subscribed"
                if pubsub_sub.state == SubscriptionState.SUBSCRIBED
                else "pending"
            )
            subscriptions.append(
                pubsub.Subscription(
                    nodeIdentifier=pubsub_sub.node.name,
                    subscriber=pubsub_sub.subscriber,
                    state=state_str,
                )
            )

        return subscriptions

    def affiliations(
        self, request: pubsub.PubSubRequest
    ) -> defer.Deferred[list[tuple[str, str]]]:
        return defer.ensureDeferred(self._affiliations(request))

    async def _affiliations(self, request: pubsub.PubSubRequest) -> list[tuple[str, str]]:
        """Retrieve the affiliations of the requestor for one or all nodes.

        @param request: The affiliations request.
            If nodeIdentifier is set, return affiliation for that specific node.
            If nodeIdentifier is None, return affiliations for all nodes.
        @return: A list of (nodeIdentifier, affiliation) tuples.
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()
        service_jid = request.recipient if request.recipient else client.jid

        affiliations = await G.storage.get_pubsub_affiliations(
            client,
            service_jid,
            entity=requestor_jid,
            node_name=request.nodeIdentifier,
        )

        return [
            (node_name, str(affiliation)) for node_name, _, affiliation in affiliations
        ]

    def create(self, request: pubsub.PubSubRequest) -> defer.Deferred[str]:
        return defer.ensureDeferred(self._create(request))

    async def _create(self, request: pubsub.PubSubRequest) -> str:
        """Create a new pubsub node.

        @param request: Node creation request.
        @return: The identifier of the created node.
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()
        service_jid = request.recipient if request.recipient else client.jid

        is_pep = self.xep_0355.is_pep(client, service_jid)

        if is_pep and requestor_jid != service_jid.userhostJID():
            log.warning(
                f"{requestor_jid} tried to create a PEP node on"
                f" {service_jid.userhostJID()}'s service, hack attempt?"
            )
            raise error.StanzaError(
                "forbidden",
                text="Only the owner of the PEP service can create PEP nodes.",
            )

        node_id = request.nodeIdentifier or f"generic/{shortuuid.uuid()}"

        config = PubsubNodeConfig()

        if request.options is not None:
            access_model_value = request.options.get(XEP_0060.OPT_ACCESS_MODEL)
            if access_model_value:
                try:
                    config.access_model = AccessModel(access_model_value)
                except ValueError:
                    pass

            publish_model_value = request.options.get(XEP_0060.OPT_PUBLISH_MODEL)
            if publish_model_value:
                try:
                    config.publish_model = PublishModel(publish_model_value)
                except ValueError:
                    pass

            # Handle max_items: "max" or any value <= 0 means unlimited (stored as 0),
            # otherwise parse as int
            max_items_value = request.options.get("pubsub#max_items")
            if max_items_value is not None:
                if max_items_value.lower() == "max":
                    config.max_items = "max"
                else:
                    try:
                        parsed_max_items = int(max_items_value)
                        # Convert any value <= 0 to "max"
                        config.max_items = (
                            "max" if parsed_max_items <= 0 else parsed_max_items
                        )
                    except ValueError:
                        pass

            # Store relationship fields and normalize empty strings to None
            config.parent = request.options.get(PARENT_VAR) or None
            config.link = request.options.get(LINK_VAR) or None

        owner_affiliation = PubsubAffiliation(
            entity=requestor_jid,
            affiliation=Affiliation.owner,
        )

        try:
            # "max" becomes 0 (unlimited), int stays as-is
            db_max_items = 0 if config.max_items == "max" else config.max_items

            node = await G.storage.set_pubsub_node(
                client=client,
                service=service_jid,
                name=node_id,
                access_model=config.access_model,
                publish_model=config.publish_model,
                affiliations=[owner_affiliation],
                max_items=db_max_items,
                pep=is_pep,
            )
        except exceptions.ConflictError:
            raise error.StanzaError("conflict", text=f"node {node_id!r} already exists.")

        if config.parent is not None or config.link is not None:
            try:
                parent_node, linked_node = await self.xep_0496.check_relationships(
                    self.client,
                    service_jid,
                    node,
                    requestor_jid,
                    config.parent,
                    config.link,
                )
            except Exception as e:
                raise error.StanzaError(
                    "not-allowed",
                    appCondition="invalid-option",
                    text=str(e),
                )

            update_values = {}

            # Set parent if explicitly provided or auto-set from linked node
            if parent_node:
                update_values["parent_node_id"] = parent_node.id

            if linked_node:
                update_values["linked_node_id"] = linked_node.id

            if update_values:
                await G.storage.update_pubsub_node(node, update_values)

        return node_id

    def default(self, request: pubsub.PubSubRequest) -> defer.Deferred[dict[str, object]]:
        return defer.ensureDeferred(self._default(request))

    async def _default(self, request: pubsub.PubSubRequest) -> dict[str, object]:
        """Return default node configuration.

        @param request: Default configuration request.
        @return: Default configuration values.
        """
        node_type = request.nodeType or "leaf"

        if node_type != "leaf":
            raise error.StanzaError(
                "not-acceptable", text=f"Unknown node type: {node_type!r}"
            )

        # we build from PubsubNodeConfig defaults
        config = PubsubNodeConfig()

        default_config: dict[str, object] = {}

        for field_name, value in config.__dict__.items():
            try:
                pubsub_var = PubsubNodeConfig._field_var_mapping[field_name]
            except KeyError:
                log.error(f"Missing field mapping for {field_name!r}, ignoring.")
                continue

            if value is not None:
                default_config[pubsub_var] = str(value)

        return default_config

    def configureGet(self, request: pubsub.PubSubRequest) -> defer.Deferred[dict]:
        return defer.ensureDeferred(self._configureGet(request))

    async def _configureGet(self, request: pubsub.PubSubRequest) -> dict:
        """Retrieve the configuration for a specific node.

        @param request: The configuration request.
        @return: Current configuration values for the node.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner or admin
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
            with_link=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        # Only admins and owner can get configuration.
        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            # Only owners can configure nodes
            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        config_values: dict[str, str | bool | list[str]] = {}

        if node.extra:
            if "title" in node.extra:
                config_values["pubsub#title"] = node.extra["title"]
            if "description" in node.extra:
                config_values["pubsub#description"] = node.extra["description"]

        if node.access_model:
            config_values["pubsub#access_model"] = str(node.access_model)

        if node.publish_model:
            config_values["pubsub#publish_model"] = str(node.publish_model)

        if node.max_items is not None:
            config_values["pubsub#max_items"] = (
                "max" if node.max_items == 0 else str(node.max_items)
            )

        if node.linked_node is not None:
            config_values[LINK_VAR] = node.linked_node.name

        if node.parent_node:
            config_values[PARENT_VAR] = node.parent_node.name

        return config_values

    def configureSet(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._configureSet(request))

    async def _configureSet(self, request: pubsub.PubSubRequest) -> None:
        """Set the configuration of a node.

        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner or admin
        @raise StanzaError: with "not-acceptable" if configuration values are invalid
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
            with_link=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_access = await self.xep_0496.has_access(
            self.client, service_jid, node, requestor_jid
        )
        if not has_access:
            raise error.StanzaError(
                "forbidden", text="Insufficient privileges to configure this node"
            )

        # Only admins and owner can modifify configuration.
        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        if request.options is None:
            return

        config = PubsubNodeConfig()
        update_values: dict = {}

        access_model_value = request.options.get(XEP_0060.OPT_ACCESS_MODEL)
        if access_model_value:
            try:
                config.access_model = AccessModel(access_model_value)
            except ValueError:
                raise error.StanzaError(
                    "not-acceptable",
                    text=f"Invalid access_model value: {access_model_value!r}",
                )

        publish_model_value = request.options.get(XEP_0060.OPT_PUBLISH_MODEL)
        if publish_model_value:
            try:
                config.publish_model = PublishModel(publish_model_value)
            except ValueError:
                raise error.StanzaError(
                    "not-acceptable",
                    text=f"Invalid publish_model value: {publish_model_value!r}",
                )

        # Max_items
        max_items_value = request.options.get("pubsub#max_items")
        if max_items_value is not None:
            max_items_value = max_items_value.strip().lower()
            if max_items_value == "max":
                # "max" means unlimited, stored as 0 in database
                db_max_items = 0
            elif max_items_value == "":
                # Empty value, we ignore
                db_max_items = None
            else:
                try:
                    db_max_items = int(max_items_value)
                    # Convert any value <= 0 to "max" (0)
                    if db_max_items <= 0:
                        db_max_items = 0
                except ValueError:
                    raise error.StanzaError(
                        "not-acceptable",
                        text=f"Invalid max_items value: {max_items_value!r}",
                    )

            # max_items can't be lower than current item count. This is not a standard
            # validation, but it may avoid catastrophic accidental item deletion.
            if db_max_items is not None and db_max_items > 0:
                items_count = await G.storage.get_pubsub_item_count(node)
                if db_max_items < items_count:
                    raise error.StanzaError(
                        "not-acceptable",
                        text=(
                            f"max_items can't be set to {db_max_items} which is lower"
                            f" than the current number of items ({items_count}). Please"
                            " increase max_items or delete items from this node first."
                        ),
                    )

            # Only update if value changed
            if db_max_items is not None and db_max_items != node.max_items:
                update_values["max_items"] = db_max_items

        title_value = request.options.get("pubsub#title")
        description_value = request.options.get("pubsub#description")

        extra = node.extra.copy() if node.extra else {}
        update_extra = False
        if title_value is not None:
            extra["title"] = title_value
            update_extra = True
        if description_value is not None:
            extra["description"] = description_value
            update_extra = True

        if config.access_model != node.access_model:
            update_values["access_model"] = config.access_model
        if config.publish_model != node.publish_model:
            update_values["publish_model"] = config.publish_model
        if update_extra:
            update_values["extra"] = extra

        # Handle relationship
        current_parent = node.parent_node.name if node.parent_node else None
        current_link = node.linked_node.name if node.linked_node else None

        # Field present in form = user wants to change it; field absent = no change
        parent_in_form = PARENT_VAR in request.options
        link_in_form = LINK_VAR in request.options

        new_parent = request.options.get(PARENT_VAR)
        new_link = request.options.get(LINK_VAR)

        parent_changed = parent_in_form and new_parent != current_parent
        link_changed = link_in_form and new_link != current_link

        if parent_changed or link_changed:
            try:
                new_parent_node, new_linked_node = (
                    await self.xep_0496.check_relationships(
                        self.client,
                        service_jid,
                        node,
                        requestor_jid,
                        new_parent,
                        new_link,
                    )
                )
            except Exception as e:
                raise error.StanzaError(
                    "not-allowed",
                    appCondition="invalid-option",
                    text=str(e),
                )

            if parent_changed:
                if new_parent_node:
                    update_values["parent_node_id"] = new_parent_node.id
                else:
                    # Clearing parent
                    update_values["parent_node_id"] = None

                await self.xep_0496.update_linking_nodes_parent(
                    node.id, new_parent_node.id if new_parent_node else None
                )

            if link_changed:
                if new_linked_node:
                    update_values["linked_node_id"] = new_linked_node.id
                else:
                    # Clearing link
                    update_values["linked_node_id"] = None

            # If link was set and check_relationships auto-set the parent, update it too
            if link_changed and new_parent_node and not parent_changed:
                # Parent was auto-set by check_relationships to match linked node's parent
                update_values["parent_node_id"] = new_parent_node.id

        if update_values:
            await G.storage.update_pubsub_node(node, update_values)

    def items(
        self,
        request: pubsub.PubSubRequest,
    ) -> defer.Deferred[
        list[domish.Element] | tuple[list[domish.Element], rsm.RSMResponse]
    ]:
        return defer.ensureDeferred(self._items(request))

    async def _items(
        self, request: rsm.PubSubRequest
    ) -> list[domish.Element] | tuple[list[domish.Element], rsm.RSMResponse]:
        """Retrieve items from a pubsub node.

        @param request: The items request containing node identifier, item identifiers,
            max items limit, and optional RSM parameters.
        @return: A tuple of (list of item elements, RSM response or None).
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor lacks retrieve privileges
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_access = await self.xep_0496.has_access(
            self.client, service_jid, node, requestor_jid
        )
        if not has_access:
            raise error.StanzaError("forbidden", text="Insufficient privileges")

        items_data, metadata = await G.storage.get_items(
            node=node,
            max_items=request.rsm.max if request.rsm else request.maxItems,
            item_ids=request.itemIdentifiers or None,
            before=request.rsm.before if request.rsm else None,
            after=request.rsm.after if request.rsm else None,
            from_index=request.rsm.index if request.rsm else None,
            desc=request.rsm is None,
            force_rsm=request.rsm is not None,
        )

        if "rsm" in metadata:
            rsm_response = rsm.RSMResponse(**metadata["rsm"])
        else:
            rsm_response = None

        return [item.data for item in items_data], rsm_response

    def retract(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._retract(request))

    async def _retract(self, request: pubsub.PubSubRequest) -> None:
        """Handle items retraction request.

        @param request: The retract request
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor lacks retract privileges
        @raise StanzaError: with "bad-request" if no item identifiers are provided
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        if not request.itemIdentifiers:
            raise error.StanzaError("bad-request", text="No item identifiers provided")

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
            with_subscriptions=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        items_data, __ = await G.storage.get_items(
            node=node,
            max_items=None,
            item_ids=request.itemIdentifiers,
        )

        existing_items = [
            item for item in items_data if item.name in request.itemIdentifiers
        ]

        if not existing_items:
            raise error.StanzaError(
                "item-not-found",
                text="No items found with the specified identifiers",
            )

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation not in (Affiliation.owner, Affiliation.publisher):
                for item in existing_items:
                    publisher_jid = item.data.getAttribute("publisher")
                    if (
                        not publisher_jid
                        or jid.JID(publisher_jid).userhostJID() != requestor_jid
                    ):
                        raise error.StanzaError(
                            "forbidden",
                            text="Insufficient privileges to retract this item",
                        )

        await G.storage.delete_pubsub_items(node, request.itemIdentifiers)

        if request.notify:
            item_elts = [item.data for item in existing_items]
            await self._send_notifications(node, "retract", item_elts)

    def purge(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._purge(request))

    async def _purge(self, request: pubsub.PubSubRequest) -> None:
        """Handle a node purge request.

        @param request: The purge request.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
            with_subscriptions=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        await G.storage.delete_pubsub_items(node, items_names=None)
        await self._send_notifications(node, "purge", [])

    def delete(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._delete(request))

    async def _delete(self, request: pubsub.PubSubRequest) -> None:
        """Handle a node deletion request.

        @param request: The delete request containing the node identifier.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_affiliations=True,
            with_parent=True,
            with_subscriptions=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_access = await self.xep_0496.has_access(
            self.client, service_jid, node, requestor_jid
        )
        if not has_access:
            raise error.StanzaError(
                "forbidden", text="Insufficient privileges to delete this node"
            )

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        # We'll generate list of nodes to delete due to cascade deletion with
        # relationships (cf. XEP-0496).
        nodes_to_delete = [node.name]

        linking_nodes = await self.xep_0496.get_linking_nodes_by_id(node.id)
        for linking_node in linking_nodes:
            nodes_to_delete.append(linking_node.name)

        descendant_nodes = await self.xep_0496.get_descendant_nodes(node)
        for descendant in descendant_nodes:
            if descendant.name not in nodes_to_delete:
                nodes_to_delete.append(descendant.name)

        # Build a map of nodes to delete (reuse already fetched nodes)
        nodes_map: dict[str, PubsubNode] = {node.name: node}
        nodes_map.update({str(ln.name): ln for ln in linking_nodes})
        nodes_map.update({str(dn.name): dn for dn in descendant_nodes})

        # Fetch any missing nodes
        for node_name in nodes_to_delete:
            if node_name not in nodes_map:
                fetched_node = await G.storage.get_pubsub_node(
                    client,
                    service_jid,
                    node_name,
                    with_affiliations=True,
                    with_parent=True,
                    with_subscriptions=True,
                )
                if fetched_node:
                    nodes_map[node_name] = fetched_node

        for node_name in nodes_to_delete:
            node_to_delete = nodes_map.get(node_name)
            if node_to_delete:
                await self._send_notifications(node_to_delete, "delete", [])

        if nodes_to_delete:
            await G.storage.delete_pubsub_node(
                profiles=None, services=[service_jid], names=nodes_to_delete
            )

    def affiliationsGet(
        self, request: pubsub.PubSubRequest
    ) -> defer.Deferred[dict[jid.JID, str]]:
        return defer.ensureDeferred(self._affiliationsGet(request))

    async def _affiliationsGet(self, request: pubsub.PubSubRequest) -> dict[jid.JID, str]:
        """Retrieve all affiliations for a specific node.

        @param request: The affiliations get request with node identifier.
        @return: Mapping of entity JIDs to their affiliations.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner or admin
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client, service_jid, request.nodeIdentifier, with_affiliations=True
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_access = await self.xep_0496.has_access(
            self.client, service_jid, node, requestor_jid
        )
        if not has_access:
            raise error.StanzaError(
                "forbidden", text="Insufficient privileges to retrieve affiliations"
            )

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        return {
            affiliation.entity: affiliation.affiliation
            for affiliation in node.affiliations
        }

    def affiliationsSet(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._affiliationsSet(request))

    async def _affiliationsSet(self, request: pubsub.PubSubRequest) -> None:
        """Set affiliations for entities on a specific node.

        @param request: The affiliations set request with node identifier and affiliations
            dict.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner or admin
        @raise StanzaError: with "not-acceptable" if affiliation values are invalid
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client, service_jid, request.nodeIdentifier, with_affiliations=True
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        has_access = await self.xep_0496.has_access(
            self.client, service_jid, node, requestor_jid
        )
        if not has_access:
            raise error.StanzaError(
                "forbidden", text="Insufficient privileges to manage affiliations"
            )

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        # Prevent owner from changing their own affiliation to non-owner
        if request.affiliations is not None:
            if requestor_jid in request.affiliations:
                new_affiliation = request.affiliations[requestor_jid]
                if new_affiliation != "owner":
                    raise error.StanzaError(
                        "forbidden", text="You can't change your own affiliation"
                    )

        # Convert affiliation strings to format expected by "set_pubsub_affiliations"
        affiliations_to_set: dict[jid.JID, Affiliation | None] = {}
        if request.affiliations is not None:
            for entity, affiliation_str in request.affiliations.items():
                if affiliation_str == "none":
                    # We set the affiliation to None to mark it for removal.
                    affiliations_to_set[entity] = None
                else:
                    try:
                        affiliation = Affiliation(affiliation_str)
                        affiliations_to_set[entity] = affiliation
                    except ValueError:
                        raise error.StanzaError(
                            "not-acceptable",
                            text=f"Invalid affiliation value: {affiliation_str!r}",
                        )

        if affiliations_to_set:
            await G.storage.set_pubsub_affiliations(
                client, service_jid, request.nodeIdentifier, affiliations_to_set
            )

    def subscriptionsGet(
        self, request: pubsub.PubSubRequest
    ) -> defer.Deferred[list[pubsub.Subscription]]:
        return defer.ensureDeferred(self._subscriptionsGet(request))

    async def _subscriptionsGet(
        self, request: pubsub.PubSubRequest
    ) -> list[pubsub.Subscription]:
        """Retrieve all subscriptions for a specific node.

        @param request: The subscriptions get request with node identifier.
        @return: List of Subscription objects for all subscribers of the node.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner or admin
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client,
            service_jid,
            request.nodeIdentifier,
            with_subscriptions=True,
            with_affiliations=True,
            with_parent=True,
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        # Convert subscriptions to Subscription objects
        subscriptions = []
        for sub in node.subscriptions:
            state_str = (
                "subscribed" if sub.state == SubscriptionState.SUBSCRIBED else "pending"
            )
            subscriptions.append(
                pubsub.Subscription(
                    nodeIdentifier=request.nodeIdentifier,
                    subscriber=sub.subscriber,
                    state=state_str,
                )
            )

        return subscriptions

    def subscriptionsSet(self, request: pubsub.PubSubRequest) -> defer.Deferred[None]:
        return defer.ensureDeferred(self._subscriptionsSet(request))

    async def _subscriptionsSet(self, request: pubsub.PubSubRequest) -> None:
        """Set subscriptions for entities on a specific node.

        This allows node owners to manage subscriptions for entities.
        Owner can approve pending subscriptions or set entities as subscribed.

        @param request: The subscriptions set request with node identifier and
            subscriptions dict.
        @raise StanzaError: with "item-not-found" if the node does not exist
        @raise StanzaError: with "forbidden" if the requestor is not an owner or admin
        @raise StanzaError: with "not-acceptable" if subscription values are invalid
        """
        client = self.client
        assert request.sender is not None
        requestor_jid = request.sender.userhostJID()

        assert request.nodeIdentifier is not None
        service_jid = request.recipient if request.recipient else client.jid

        node = await G.storage.get_pubsub_node(
            client, service_jid, request.nodeIdentifier, with_affiliations=True
        )

        if node is None:
            raise error.StanzaError("item-not-found", text="Node does not exist")

        if not G.host.memory.is_admin_jid(requestor_jid):
            affiliation = next(
                (
                    aff_data.affiliation
                    for aff_data in node.affiliations
                    if aff_data.entity == requestor_jid
                ),
                None,
            )

            if affiliation != Affiliation.owner:
                raise error.StanzaError("forbidden", text="Insufficient privileges")

        if request.subscriptions is None:
            return

        # Validate subscriptions from the request and convert them to the format expected
        # by "set_pubsub_subscriptions"
        subscriptions_to_set: dict[jid.JID, SubscriptionState | None] = {}
        for subscription in request.subscriptions:
            assert subscription.subscriber is not None
            assert subscription.state is not None
            entity = subscription.subscriber
            subscription_state = subscription.state

            match subscription_state:
                case "none":
                    subscriptions_to_set[entity] = None
                case "subscribed":
                    entity_affiliation = next(
                        (
                            aff_data.affiliation
                            for aff_data in node.affiliations
                            if aff_data.entity == entity
                        ),
                        None,
                    )

                    if entity_affiliation == Affiliation.outcast:
                        raise error.StanzaError(
                            "not-acceptable",
                            text=f"Cannot subscribe outcast entity {entity!r}",
                        )

                    access_model = node.access_model or AccessModel.open
                    if access_model == AccessModel.whitelist:
                        if entity_affiliation not in (
                            Affiliation.owner,
                            Affiliation.publisher,
                            Affiliation.member,
                        ):
                            raise error.StanzaError(
                                "not-acceptable",
                                text=f"Entity {entity!r} is not on the whitelist",
                            )

                    subscriptions_to_set[entity] = SubscriptionState.SUBSCRIBED
                case "pending":
                    subscriptions_to_set[entity] = SubscriptionState.PENDING
                case "unconfigured":
                    # FIXME: unconfigured is treated as subscribed for now
                    subscriptions_to_set[entity] = SubscriptionState.SUBSCRIBED
                case _:
                    raise error.StanzaError(
                        "not-acceptable",
                        text=f"Invalid subscription state: {subscription_state!r}",
                    )

        await G.storage.set_pubsub_subscriptions(node, subscriptions_to_set)

    # Notifications

    def _get_notification_recipients(
        self, node: PubsubNode
    ) -> dict[jid.JID, list[PubsubSub | None]]:
        """Get all notification recipients for a node.

        Filters the node subscriptions and affiliations to determine who should receive
        notifications. Recipients include:
        - All subscribers with SUBSCRIBED state
        - All owners

        @param node: The pubsub node (must have affiliations and subscriptions loaded).
        @return: A mapping of recipient JIDs to their subscription list.
            Recipients are grouped by JID to ensure only one notification per entity.
            None is used for owner when there is no subscription.
        """
        recipients: dict[jid.JID, list[PubsubSub | None]] = {}

        # Subscribers
        if node.subscriptions:
            for sub in node.subscriptions:
                if sub.state == SubscriptionState.SUBSCRIBED:
                    subscriber_jid = sub.subscriber
                    subscriptions = recipients.setdefault(subscriber_jid, [])
                    subscriptions.append(sub)

        # Owners
        if node.affiliations:
            for aff in node.affiliations:
                if aff.affiliation == Affiliation.owner:
                    owner_jid = aff.entity
                    subscriptions = recipients.setdefault(owner_jid, [])
                    subscriptions.append(None)

        return recipients

    async def _send_notification(
        self,
        node: PubsubNode,
        event_type: NotificationEventType,
        node_id: str,
        subscriber: jid.JID,
        item_elts: list[domish.Element],
    ) -> None:
        """Send a single notification to a subscriber.

        @param node: The pubsub node (to check if it's a PEP node).
        @param event_type: The type of event.
        @param node_id: The node identifier.
        @param subscriber: The subscriber JID.
        @param item_elts: The items to include in the notification.
        """
        message_elt = domish.Element((None, "message"))
        message_elt["from"] = node.service.full()
        message_elt["to"] = subscriber.full()
        message_elt["id"] = f"{shortuuid.uuid()}"

        event_elt = message_elt.addElement((NS_PUBSUB_EVENT, "event"))

        # Create the event element (e.g., <items/>, <delete/>, etc.)
        event_type_elt = event_elt.addElement(event_type)
        event_type_elt["node"] = node_id

        match event_type:
            case "items":
                for item_elt in item_elts:
                    item_clone_elt = element_copy(item_elt)
                    # We inherit from parents xmlns
                    item_clone_elt.uri = item_clone_elt.defaultUri = None
                    event_type_elt.addChild(item_clone_elt)
            case "retract":
                for item_elt in item_elts:
                    retract_elt = event_type_elt.addElement("retract")
                    retract_elt["id"] = item_elt.getAttribute("id")
            case "delete" | "purge":
                # Empty elements, no children needed
                pass
            case "configuration":
                raise NotImplementedError
            case _:
                raise exceptions.InternalError(
                    f"Unknown notification event type: {event_type!r}"
                )

        if node.pep:
            await self.xep_0356.send_privileged_message(self.client, message_elt)
        else:
            await self.client.a_send(message_elt)

    async def _send_notifications(
        self,
        node: PubsubNode,
        event_type: NotificationEventType,
        item_elts: list[domish.Element],
    ) -> None:
        """Send notifications to all subscribers and owners of a node in parallel.

        @param node: The pubsub node to send notifications for (must have affiliations and
            subscriptions loaded).
        @param event_type: The type of event ('items', 'delete', 'purge', etc.).
        @param item_elts: The items to include in the notifications.
        """
        recipients = self._get_notification_recipients(node)

        if not recipients:
            return

        deferreds = [
            defer.ensureDeferred(
                self._send_notification(
                    node, event_type, node.name, recipient_jid, item_elts
                )
            )
            for recipient_jid, __ in recipients.items()
        ]

        # We send notifications in parallel
        results = await defer.DeferredList(deferreds, consumeErrors=True)

        for success, result in results:
            if not success:
                log.error(f"Failed to send notification: {result}")

    async def _on_presence_received(
        self,
        client: SatXMPPComponent,
        from_jid: jid.JID,
        presence_elt: domish.Element,
    ) -> None:
        """Handle presence for PEP auto-subscription.

        When a subscriber comes online with +notify capability, send them the last item
        from each relevant PEP node where they have presence subscription to the node
        owner.

        @param client: The pubsub component client.
        @param from_jid: Full JID of the subscriber sending presence.
        @param presence_elt: The presence stanza.
        """
        if not client.is_component:
            return

        requestor_jid = from_jid.userhostJID()

        caps_hash = G.host.memory.get_entity_datum(client, from_jid, C.ENTITY_CAP_HASH)
        if not caps_hash:
            return

        try:
            disco_infos = G.host.memory.disco.hashes[caps_hash]
        except KeyError:
            return

        notify_nodes = [f[:-7] for f in disco_infos.features if f.endswith("+notify")]
        if not notify_nodes:
            return

        # Build list of publishers (entities the requestor can receive notifications from)

        # Self-subscription
        publishers = {requestor_jid}

        try:
            roster = await self.xep_0356.get_roster(client, requestor_jid)
        except exceptions.PermissionError:
            roster = {}

        # Add publishers that the requestor has subscribed to.
        publishers.update(
            roster_jid
            for roster_jid, roster_item in roster.items()
            if roster_item.subscriptionTo
        )

        try:
            last_items = await G.storage.get_pep_last_items(
                client,
                list(publishers),
                notify_nodes,
                [AccessModel.open, AccessModel.presence],
            )
        except Exception:
            log.warning(f"Failed to get PEP last items for {from_jid}")
            return

        # Send notifications
        for node, item in last_items:
            # For self-subscription, no need to check access
            if node.service != requestor_jid:
                if not await self.xep_0496.has_access(
                    client, node.service, node, requestor_jid
                ):
                    continue

            item_elt = cast(domish.Element, item.data)
            await self._send_notification(
                node, "items", str(node.name), from_jid, [item_elt]
            )


class PubsubService(rsm.PubSubService):
    def __init__(self, component: "PubsubComponent"):
        self.component = component
        super().__init__(resource=PubsubResource(self))
        self.discoidentity = {
            "category": "pubsub",
            "type": "service",
            "name": "Libervia Pubsub",
        }

    @property
    def client(self) -> SatXMPPComponent:
        return self.component.client

    async def on_extended_disco(
        self,
        client: SatXMPPComponent,
        iq_elt: domish.Element,
        ext_disco_options: ExtDiscoOptions,
    ) -> ExtDiscoResult:
        """Handle pubsub extended disco requests."""
        query_elt = iq_elt.query
        assert query_elt is not None
        node_name = query_elt.getAttribute("node")

        try:
            service_jid = jid.JID(iq_elt["to"])
        except (KeyError, RuntimeError):
            service_jid = client.jid

        try:
            requestor_jid = jid.JID(iq_elt["from"])
        except (KeyError, RuntimeError):
            requestor_jid = client.jid

        nodes = await G.storage.get_pubsub_nodes(
            client,
            service=service_jid,
            node_name=node_name,
            linked_node=ext_disco_options.linked_nodes,
            depth=ext_disco_options.depth,
            with_items="items" in ext_disco_options.type,
            with_affiliations=True,
            with_parent=True,
            with_link=True,
        )

        result_items: list[DiscoPubsubNode | DiscoPubsubItem] = []

        for node in nodes:
            if ext_disco_options.depth == 0:
                disco_node = DiscoPubsubNode.from_sqlalchemy(node, service_jid)
                if "items" in ext_disco_options.type:
                    try:
                        disco_node.items = [
                            DiscoPubsubItem.from_sqlalchemy(item, node.name, service_jid)
                            for item in node.items
                        ]
                    except Exception:
                        pass
            else:
                disco_node = DiscoPubsubNode.from_sqlalchemy_full_hierarchy(
                    node, service_jid
                )

            filtered_node = (
                await self.component.ext_disco.filter_node_hierarchy_by_access(
                    client,
                    service_jid,
                    disco_node,
                    requestor_jid,
                    depth=ext_disco_options.depth,
                    linked_nodes=ext_disco_options.linked_nodes,
                )
            )
            if filtered_node is None:
                continue

            if "nodes" in ext_disco_options.type:
                result_items.append(filtered_node)

            if "items" in ext_disco_options.type and filtered_node.items:
                result_items.extend(filtered_node.items)
                filtered_node.items = None

        return ExtDiscoResult(result_items)


class PubsubComponent:
    IMPORT_NAME = IMPORT_NAME
    verbose = 0

    def __init__(self, host):
        self._client: SatXMPPComponent | None = None
        self.ext_disco = cast(XEP_0499, host.plugins.get("XEP-0499"))
        self.xep_0355 = cast(XEP_0355, G.host.plugins["XEP-0355"])
        self.pubsub_service = PubsubService(self)

    @property
    def client(self) -> SatXMPPComponent:
        assert self._client is not None
        return self._client

    def get_handler(self, __) -> PubsubService:
        return self.pubsub_service

    def profile_connecting(self, client: SatXMPPEntity) -> None:
        self._client = cast(SatXMPPComponent, client)
        self.ext_disco.register_handler(self.pubsub_service.on_extended_disco)
        delegation_data = client.get_plugin_data("XEP_0355", DelegationData)
        delegation_data.delegation_received.addCallback(self._on_delegation_received)

    def _on_delegation_received(self, __) -> None:
        """Check that pubsub is delegated.

        NS_PUBSUB is needed to manage PEP nodes.
        """
        if pubsub.NS_PUBSUB not in self.xep_0355.delegated_namespaces:
            log.warning(
                "Didn't get pubsub delegation from server, can't act as a PEP service"
            )


@implementer(itx_xmpp.IDisco)
class PubsubHandler(XMPPHandler):

    def getDiscoInfo(
        self, requestor, target, nodeIdentifier=""
    ) -> list[disco.DiscoFeature]:
        return [disco.DiscoFeature(NS_AV_CONFERENCES)]
