#!/usr/bin/env python3

# Libervia plugin for Pubsub Node Relationships (XEP-0496).
# 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 typing import TYPE_CHECKING, cast

from sqlalchemy import select, update
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber.jid import JID
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from tx_xmpp import disco, itx_xmpp
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 SatXMPPEntity
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.memory.sqla_mapping import (
    AccessModel,
    Affiliation,
    PublishModel,
    PubsubNode,
    SubscriptionState,
)
from libervia.backend.plugins.plugin_xep_0356 import XEP_0356
from libervia.backend.tools.utils import aio

if TYPE_CHECKING:
    from libervia.backend.core.main import LiberviaBackend

log = getLogger(__name__)

PLUGIN_INFO = {
    C.PI_NAME: "Pubsub Node Relationships",
    C.PI_IMPORT_NAME: "XEP-0496",
    C.PI_TYPE: "XEP",
    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
    C.PI_PROTOCOLS: ["XEP-0496"],
    C.PI_DEPENDENCIES: [
        "XEP-0060",
        "XEP-0356",
    ],
    C.PI_RECOMMENDATIONS: [],
    C.PI_MAIN: "XEP_0496",
    C.PI_HANDLER: "yes",
    C.PI_DESCRIPTION: _("""Pubsub node relationships with parent and link support."""),
}

NS_PUBSUB_RELATIONSHIPS = "urn:xmpp:pubsub-relationships:0"
PARENT_VAR = f"{{{NS_PUBSUB_RELATIONSHIPS}}}parent"
LINK_VAR = f"{{{NS_PUBSUB_RELATIONSHIPS}}}link"


class XEP_0496:

    def __init__(self, host: "LiberviaBackend") -> None:
        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
        self._xep_0356 = cast(XEP_0356, host.plugins["XEP-0356"])
        host.register_namespace("pubsub-relationships", NS_PUBSUB_RELATIONSHIPS)

    def get_handler(self, client) -> "XEP_0496_handler":
        return XEP_0496_handler()

    async def _check_cycle_recursive(
        self,
        client: SatXMPPEntity,
        service: JID,
        visited: set[str],
        parent: str | None,
        link: str | None,
    ) -> str | None:
        """Detect recursively if setting a relationship would create a cycle.

        @param client: Client instance.
        @param service: The pubsub service JID.
        @param visited: Names of nodes already visited. Must be initialised with child
            node name.
        @param parent: The parent node identifier (if any).
        @param link: The linked node identifier (if any).
        @return: The node name that would create a cycle, or None if no cycle.
        """
        for node_name, is_parent in ((parent, True), (link, False)):
            if node_name is not None:
                if node_name in visited:
                    return node_name
                else:
                    visited.add(node_name)
                    related_node = await G.storage.get_pubsub_node(
                        client, service, node_name, with_parent=True, with_link=True
                    )
                    if related_node is None:
                        node_type = "parent" if is_parent else "link"
                        raise exceptions.NotFound(
                            f"Invalid {node_type} node: {node_name}. Visited: {visited}"
                        )
                    if is_parent:
                        new_parent = (
                            related_node.parent_node.name
                            if related_node.parent_node
                            else None
                        )
                    else:
                        # We don't check parents for linking nodes, as they have the same
                        # as linked nodes, which will result in conflicts.
                        new_parent = None

                    new_link = (
                        related_node.linked_node.name
                        if related_node.linked_node
                        else None
                    )

                    cycling_node = await self._check_cycle_recursive(
                        client,
                        service,
                        visited,
                        new_parent,
                        new_link,
                    )
                    if cycling_node is not None:
                        return cycling_node

    @aio
    async def get_linking_nodes_by_id(self, linked_node_id: int) -> list[PubsubNode]:
        """Get all nodes that have a link relationship to the given node by ID.

        @param linked_node_id: The linked node ID.
        @return: Nodes that link to this node.
        """
        async with G.storage.session() as session:
            stmt = select(PubsubNode).where(PubsubNode.linked_node_id == linked_node_id)
            result = await session.execute(stmt)
        return result.unique().scalars().all()

    async def check_relationships(
        self,
        client: SatXMPPEntity,
        service: JID,
        node: PubsubNode,
        entity_jid: jid.JID,
        new_parent: str | None,
        new_link: str | None,
    ) -> tuple[PubsubNode | None, PubsubNode | None]:
        """Validate relationship changes according to XEP-0496 rules.

        @param client: Client instance.
        @param service: The pubsub service JID.
        @param node: The node being configured.
        @param entity_jid: The entity JID to check access for.
        @param new_parent: The new parent node identifier (if any).
        @param new_link: The new linked node identifier (if any).
        @return: Tuple of (parent_node, linked_node) if they were fetched, or None.
        @raises: exceptions on validation errors.
        """
        if new_parent is not None and node.linked_node_id is not None:
            raise exceptions.ConflictError(
                "Cannot set parent on a linking node; parent is automatically managed."
            )
        if (
            new_parent is None
            and node.linked_node_id is not None
            and node.parent_node_id is not None
        ):
            raise exceptions.ConflictError(
                "Cannot clear parent on a linking node; parent is automatically managed."
            )

        parent_node: PubsubNode | None = None
        linked_node: PubsubNode | None = None

        if new_parent is not None:
            # Validate if parent node exists and entity has the right to access it.
            parent_node = await G.storage.get_pubsub_node(
                client, service, new_parent, with_affiliations=True, with_parent=True
            )
            if parent_node is None:
                raise exceptions.NotFound(f"Parent node {new_parent!r} does not exist")

            has_parent_access = await self.has_access(
                client, service, parent_node, entity_jid
            )
            if not has_parent_access:
                raise exceptions.PermissionError(
                    f"You do not have access to parent node {new_parent!r} or one of its"
                    " parents, you can't use it as parent."
                )
            parent_node_id = parent_node.id
        else:
            parent_node_id = node.parent_node_id

        if new_link is not None:
            linked_node = await G.storage.get_pubsub_node(
                client, service, new_link, with_parent=True
            )
            if linked_node is None:
                raise exceptions.NotFound(f"Linked node {new_link!r} does not exist")

            # If linked node has a parent, linking node must have the same parent
            if linked_node.parent_node_id is not None:
                # If linking node has a different parent (not None, not matching), reject
                if (
                    parent_node_id is not None
                    and parent_node_id != linked_node.parent_node_id
                ):
                    raise exceptions.ConflictError(
                        "Linking node must have the same parent as linked node"
                        f" {new_link!r}"
                    )
                # If linking node has no parent, auto-set it to match linked node's parent
                if parent_node_id is None:
                    parent_node_id = linked_node.parent_node_id
                    # Fetch the parent node so it can be used in update_values
                    parent_node = await G.storage.get_pubsub_node(
                        client, service, node_id=parent_node_id
                    )

        # Check for cycles
        cycle_node = await self._check_cycle_recursive(
            client, service, {node.name}, new_parent, new_link
        )
        if cycle_node:
            raise exceptions.ConflictError(
                "Setting this relationship would create a cycle through node"
                " {cycle_node!r}"
            )

        return parent_node, linked_node

    async def _check_node_access(
        self,
        client: SatXMPPEntity,
        service: JID,
        node: PubsubNode,
        entity_jid: jid.JID,
    ) -> bool:
        """Check if an entity has access to a single node.

        @param client: Client instance.
        @param service: The pubsub service JID.
        @param node: The node to check access for. It must have ``affiliations`` loaded.
        @param entity_jid: The entity JID to check access for.
        @return: True if the entity has access to the node.
        """
        affiliation = next(
            (
                aff_data.affiliation
                for aff_data in node.affiliations
                if aff_data.entity == entity_jid
            ),
            None,
        )

        access_model = node.access_model or AccessModel.open

        if access_model == AccessModel.open:
            return True
        elif access_model == AccessModel.presence:
            return await self._check_presence_access(client, service, node, entity_jid)
        elif access_model == AccessModel.whitelist:
            return affiliation in (
                Affiliation.owner,
                Affiliation.publisher,
                Affiliation.member,
            )
        return False

    async def _check_presence_access(
        self,
        client: SatXMPPEntity,
        service: JID,
        node: PubsubNode,
        entity_jid: jid.JID,
    ) -> bool:
        """Check presence-based access for a node.

        @param client: Client instance.
        @param service: The pubsub service JID.
        @param node: The pubsub node.
        @param entity_jid: The entity JID to check access for.
        @return: True if requester has "from" or "both" subscription with publisher.
        """
        if not node.pep:
            raise NotImplementedError("Presence access model is only implemented for PEP")

        # For PEP, owner/publisher is the service JID.
        publisher_jid = service.userhostJID()

        try:
            roster = await self._xep_0356.get_roster(client, publisher_jid)
        except Exception:
            log.warning(
                f"Failed to get roster for {publisher_jid} when checking presence access."
            )
            return False

        roster_item = roster.get(entity_jid)
        if roster_item is None:
            return False

        # According to XEP-0060, subscription "from" or "both" are accepted.
        return roster_item.subscriptionFrom

    async def has_access(
        self, client: SatXMPPEntity, service: JID, node: PubsubNode, entity_jid: jid.JID
    ) -> bool:
        """Check if an entity has access to a node and all its parents.

        @param client: Client instance.
        @param service: The pubsub service JID.
        @param node: The node to check access for.
        @param entity_jid: The entity JID to check access for.
        @return: True if the entity has access to the node and all its parents.
        """
        if not await self._check_node_access(client, service, node, entity_jid):
            return False

        current_node = node

        while current_node.parent_node:
            parent_node = await G.storage.get_pubsub_node(
                client,
                service,
                current_node.parent_node.name,
                with_affiliations=True,
                with_parent=True,
            )
            if parent_node is None:
                break

            if not await self._check_node_access(
                client, service, parent_node, entity_jid
            ):
                return False

            current_node = parent_node

        return True

    def _check_node_publish_rights(self, node: PubsubNode, entity_jid: jid.JID) -> bool:
        """Check if an entity has publish rights to a single node.

        @param node: The node to check publish rights for. It must have ``affiliations``
            and ``subscriptions`` loaded.
        @param entity_jid: The entity JID to check publish rights for.
        @return: True if the entity has publish rights to the node.
        """
        affiliation = next(
            (
                aff_data.affiliation
                for aff_data in node.affiliations
                if aff_data.entity == entity_jid
            ),
            None,
        )

        publish_model = node.publish_model or PublishModel.publishers

        if publish_model == PublishModel.open:
            return True
        elif publish_model == PublishModel.publishers:
            return affiliation in (Affiliation.owner, Affiliation.publisher)
        elif publish_model == PublishModel.subscribers:
            if affiliation in (Affiliation.owner, Affiliation.publisher):
                return True
            # Check if subscribed
            return any(
                sub.subscriber == entity_jid and sub.state == SubscriptionState.SUBSCRIBED
                for sub in node.subscriptions
            )
        return False

    async def has_publish_rights(
        self, client: SatXMPPEntity, service: JID, node: PubsubNode, entity_jid: jid.JID
    ) -> bool:
        """Check if an entity has publish rights to a node and all its parents.

        @param client: Client instance.
        @param service: The pubsub service JID.
        @param node: The node to check publish rights for. It must have ``affiliations``
            and ``subscriptions`` loaded. The later is only used in case of
            ``subscribers`` access model.
        @param entity_jid: The entity JID to check publish rights for.
        @return: True if the entity has publish rights to the node and all its parents.
        """
        if not self._check_node_publish_rights(node, entity_jid):
            return False

        # Check the entire hierarchy
        current_node = node
        while current_node.parent_node:
            parent_node = await G.storage.get_pubsub_node(
                client,
                service,
                current_node.parent_node.name,
                with_affiliations=True,
                with_subscriptions=True,
            )
            if parent_node is None:
                log.error(
                    f"Node parent doesn't exist: {client.profile=} {service=}"
                    f" node_name={current_node.parent_node.name}"
                )
                break

            if not self._check_node_publish_rights(parent_node, entity_jid):
                return False

            current_node = parent_node

        return True

    async def get_descendant_nodes(self, node: PubsubNode) -> list[PubsubNode]:
        """Get all descendant of a node.

        @param node: Parent node.
        @return: List of all descendant nodes.
        """
        descendants: list[PubsubNode] = []
        queue: list[PubsubNode] = [node]

        while queue:
            current_node = queue.pop(0)
            children = await G.storage.get_pubsub_node_children(current_node)

            for child in children:
                descendants.append(child)
                queue.append(child)

        return descendants

    @aio
    async def update_linking_nodes_parent(
        self, linked_node_id: int, new_parent_id: int | None
    ) -> None:
        """Update parent_node_id for all linking nodes when linked node parent changes.

        This method is called when a linked node's parent is updated. All nodes that
        link to this node must have their parent_node_id updated to match.

        @param linked_node_id: The linked node ID.
        @param new_parent_id: The new parent node ID (or None to clear parent).
        """
        async with G.storage.session() as session:
            stmt = (
                update(PubsubNode)
                .where(PubsubNode.linked_node_id == linked_node_id)
                .values(parent_node_id=new_parent_id)
            )
            await session.execute(stmt)
            await session.flush()


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

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

    def getDiscoItems(
        self, requestor: JID, target: JID, nodeIdentifier: str = ""
    ) -> list[disco.DiscoItem]:
        return []
