#!/usr/bin/env python3


# Libervia plugin for managing xep-0115
# 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 base64 import b64encode
from dataclasses import dataclass, field
from hashlib import sha1
from typing import TYPE_CHECKING

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

from libervia.backend import G
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

log = getLogger(__name__)


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

PRESENCE = "/presence"
NS_ENTITY_CAPABILITY = "http://jabber.org/protocol/caps"
NS_CAPS_OPTIMIZE = "http://jabber.org/protocol/caps#optimize"
CAPABILITY_UPDATE = PRESENCE + '/c[@xmlns="' + NS_ENTITY_CAPABILITY + '"]'

IMPORT_NAME = "XEP-0115"

PLUGIN_INFO = {
    C.PI_NAME: "XEP 0115 Plugin",
    C.PI_IMPORT_NAME: IMPORT_NAME,
    C.PI_TYPE: "XEP",
    C.PI_MODES: C.PLUG_MODE_BOTH,
    C.PI_PROTOCOLS: ["XEP-0115"],
    C.PI_DEPENDENCIES: [],
    C.PI_MAIN: "XEP_0115",
    C.PI_HANDLER: "yes",
    C.PI_DESCRIPTION: _("""Implementation of entity capabilities"""),
}


@dataclass
class CapsClientData:
    """Per-client data for XEP-0115 capabilities."""

    #: True if caps optimisation is available
    caps_optimize: bool = False
    #: True if a presence with caps has been sent
    caps_sent: bool = False
    #: Our own capability hash
    caps_hash: str | None = None
    #: The <c/> element built for this client's presence
    caps_elt: domish.Element | None = field(default=None)


class ByteIdentity:
    """This class manage identity as bytes (needed for i;octet sort)

    It is used for the hash generation.
    """

    def __init__(self, identity: disco.DiscoIdentity):
        assert isinstance(identity, disco.DiscoIdentity)
        self.category = identity.category.encode("utf-8")
        self.idType = identity.type.encode("utf-8")
        self.name = identity.name.encode("utf-8") if identity.name else b""
        self.lang = identity.lang.encode("utf-8") if identity.lang else b""

    def __bytes__(self):
        return b"%s/%s/%s/%s" % (self.category, self.idType, self.lang, self.name)


class XEP_0115:
    def __init__(self, host: "LiberviaBackend") -> None:
        self.host = host
        log.info(_("Plugin XEP_0115 initialization"))
        host.trigger.add("Presence send", self._presence_trigger)

    def get_handler(self, client: SatXMPPEntity) -> XEP_0115_handler:
        return XEP_0115_handler(self)

    async def _prepare_caps(self, client: SatXMPPEntity) -> None:
        # we have to calculate hash for client
        # because disco infos/identities may change between clients

        data = client.get_plugin_data(IMPORT_NAME, CapsClientData)

        # optimize check
        data.caps_optimize = await G.host.has_feature(client, NS_CAPS_OPTIMIZE)
        if data.caps_optimize:
            log.info(_("Caps optimisation enabled"))
            data.caps_sent = False
        else:
            log.warning(_("Caps optimisation not available"))

        # hash generation
        assert data.caps_hash is None, "caps_hash already set"
        _infos = await client.discoHandler.info(client.jid, client.jid, "")
        disco_infos = disco.DiscoInfo()
        for item in _infos:
            disco_infos.append(item)
        cap_hash = self.generate_hash(disco_infos)
        data.caps_hash = cap_hash
        log.info(
            "Our capability hash has been generated: [{cap_hash}]".format(
                cap_hash=cap_hash
            )
        )
        log.debug("Generating capability domish.Element")
        c_elt = domish.Element((NS_ENTITY_CAPABILITY, "c"))
        c_elt["hash"] = "sha-1"
        c_elt["node"] = C.APP_URL
        c_elt["ver"] = cap_hash
        data.caps_elt = c_elt
        if data.caps_optimize:
            data.caps_sent = False
        if cap_hash not in G.host.memory.disco.hashes:
            G.host.memory.disco.hashes[cap_hash] = disco_infos
            G.host.memory.update_entity_data(
                client, client.jid, C.ENTITY_CAP_HASH, cap_hash
            )

    def _presence_add_elt(self, client: SatXMPPEntity, obj: domish.Element) -> None:
        data = client.get_plugin_data(IMPORT_NAME, CapsClientData)
        if data.caps_optimize:
            if data.caps_sent:
                return
            data.caps_sent = True
        if data.caps_elt is not None:
            obj.addChild(data.caps_elt)

    def _presence_trigger(
        self,
        client: SatXMPPEntity,
        obj: domish.Element,
        presence_d: defer.Deferred[domish.Element],
    ) -> bool:
        data = client.get_plugin_data(IMPORT_NAME, CapsClientData)
        if data.caps_elt is None:
            presence_d.addCallback(
                lambda __: defer.ensureDeferred(self._prepare_caps(client))
            )

        presence_d.addCallback(lambda __: self._presence_add_elt(client, obj))
        return True

    def _update(self, presence_elt: domish.Element, client: SatXMPPEntity) -> None:
        defer.ensureDeferred(self.update(client, presence_elt))

    async def update(self, client: SatXMPPEntity, presence_elt: domish.Element) -> None:
        """
        Manage the capabilities of the entity

        Check if we know the version of this capabilities and get the capabilities if
        necessary

        @param presence_elt: <presence/> element to parse.
        """
        from_jid = jid.JID(presence_elt["from"])
        c_elem = next(presence_elt.elements(NS_ENTITY_CAPABILITY, "c"))
        try:
            c_ver = c_elem["ver"]
            c_hash = c_elem["hash"]
            c_node = c_elem["node"]
        except KeyError:
            log.warning(_("Received invalid capabilities tag: %s") % c_elem.toXml())
            return

        if c_ver in G.host.memory.disco.hashes:
            # we already know the hash, we update the jid entity
            log.debug(
                "hash [%(hash)s] already in cache, updating entity [%(jid)s]"
                % {"hash": c_ver, "jid": from_jid.full()}
            )
            G.host.memory.update_entity_data(client, from_jid, C.ENTITY_CAP_HASH, c_ver)
            return

        if c_hash != "sha-1":  # unknown hash method
            log.warning(
                _(
                    "Unknown hash method for entity capabilities: [{hash_method}] "
                    "(entity: {entity_jid}, node: {node})"
                ).format(hash_method=c_hash, entity_jid=from_jid, node=c_node)
            )
            return

        try:
            # Per XEP-0115 §6.2, include the caps node in the disco#info request
            caps_node = f"{c_node}#{c_ver}"
            disco_infos = await G.host.get_disco_infos(client, from_jid, node=caps_node)
        except error.ConnectionDone:
            return
        except Exception as e:
            log.error(
                _("Couldn't retrieve disco info for {jid}: {error}").format(
                    jid=from_jid.full(), error=str(e)
                )
            )
            return

        # Validate disco info structure per XEP-0115 §5.4
        if not self._validate_caps(from_jid, c_ver, c_node, disco_infos):
            return

        # disco is validated, we can compute hash
        cap_hash = self.generate_hash(disco_infos)

        # Compare computed hash with received verification string
        if cap_hash != c_ver:
            log.warning(
                _(
                    "Computed hash differs from received hash for {jid}:\n"
                    "received: [{received}]\n"
                    "computed: [{computed}]\n"
                    "(node: {node})"
                ).format(
                    jid=from_jid.full(),
                    received=c_ver,
                    computed=cap_hash,
                    node=c_node,
                )
            )
            # It's invalid, we exit early and won't cache.
            return

        # We're all good, we can cache the disco info
        G.host.memory.disco.hashes[cap_hash] = disco_infos
        G.host.memory.update_entity_data(client, from_jid, C.ENTITY_CAP_HASH, cap_hash)
        log.debug(f"Caps validated and cached for {from_jid}: {cap_hash}")

    def generate_hash(self, services: disco.DiscoInfo) -> str:
        """Generate a unique hash for given service

        Hash algorithm is the one described in XEP-0115 §5.1 (Generation Method).
        @param services: disco infos as returned by discoHandler.info()

        """
        # Step 1: initialize empty string S
        s = []
        # Step 2: sort identities by category, then type, then xml:lang (i;octet
        # collation)
        byte_identities = [
            ByteIdentity(service)
            for service in services
            if isinstance(service, disco.DiscoIdentity)
        ]
        # i;octet (RFC 4790 §9.3) sorting.
        # Python's bytes comparison is equivalent to i;octet
        byte_identities.sort(key=lambda i: (i.category, i.idType, i.lang))
        # Step 3: append each identity formatted as cat/type/lang/name followed by '<'
        for identity in byte_identities:
            s.append(bytes(identity))
            s.append(b"<")
        # features
        byte_features = [
            service.encode("utf-8")
            for service in services
            if isinstance(service, disco.DiscoFeature)
        ]
        # Step 4: sort the features
        byte_features.sort()
        # Step 5: append each feature followed by '<'
        for feature in byte_features:
            s.append(feature)
            s.append(b"<")

        # Step 6: sort forms by FORM_TYPE; Step 7: process each form's fields
        ext = list(services.extensions.values())
        ext.sort(key=lambda f: f.formNamespace.encode("utf-8"))
        for extension in ext:
            # Step 7.a: append FORM_TYPE field's value (formNamespace) followed by '<'
            s.append(extension.formNamespace.encode("utf-8"))
            s.append(b"<")
            # Step 7.b: sort fields by "var" attribute
            fields = extension.fieldList
            fields.sort(key=lambda f: f.var.encode("utf-8"))
            for field in fields:
                # Step 7.c: "For each field other than FORM_TYPE"
                if field.var == "FORM_TYPE":
                    continue
                # Step 7.c.a: append var attribute followed by '<'
                s.append(field.var.encode("utf-8"))
                s.append(b"<")
                # Step 7.c.b: sort values
                values = [v.encode("utf-8") for v in field.values]
                values.sort()
                # Step 7.c.c: append each value followed by '<'
                for value in values:
                    s.append(value)
                    s.append(b"<")

        # Step 9: compute verification string (SHA-1 -> Base64)
        cap_hash = b64encode(sha1(b"".join(s)).digest()).decode("utf-8")
        log.debug(_("Capability hash generated: [{cap_hash}]").format(cap_hash=cap_hash))
        return cap_hash

    def _validate_caps(
        self,
        from_jid: jid.JID,
        c_ver: str,
        c_node: str,
        disco_infos: disco.DiscoInfo,
    ) -> bool:
        """Validate capabilities disco info structure.

        @param from_jid: The JID that sent the presence with caps.
        @param c_ver: The verification string from the caps element.
        @param c_node: The node attribute from the caps element.
        @param disco_infos: The fetched disco#info response.
        @return: True if disco info is well-formed, False otherwise.
        """
        # §5.4 step 3.c: duplicate identities with same category/type/lang/name
        seen_identities = set()
        for item in disco_infos:
            if isinstance(item, disco.DiscoIdentity):
                lang = item.lang or ""
                identity_key = (item.category, item.type, lang)
                if identity_key in seen_identities:
                    log.warning(
                        _(
                            "Duplicate identity found in caps for {jid}: category={cat},"
                            " type={typ}, lang={lang}"
                        ).format(
                            jid=from_jid.full(),
                            cat=item.category,
                            typ=item.type,
                            lang=lang,
                        )
                    )
                    return False
                seen_identities.add(identity_key)

        # §5.4 step 3.d: duplicate features
        seen_features = set()
        for item in disco_infos:
            if isinstance(item, disco.DiscoFeature):
                if item in seen_features:
                    log.warning(
                        _("Duplicate feature found in caps for {jid}: {feat}").format(
                            jid=from_jid.full(), feat=item
                        )
                    )
                    return False
                seen_features.add(item)

        # §5.4 step 3.e: duplicate FORM_TYPEs or FORM_TYPE with multiple values
        seen_form_types = set()
        for form_type, form in disco_infos.extensions.items():
            if form_type in seen_form_types:
                log.warning(
                    _("Duplicate FORM_TYPE found in caps for {jid}: {ft}").format(
                        jid=from_jid.full(), ft=form_type
                    )
                )
                return False

            form_type_field = None
            for field in form.fieldList:
                if field.var == "FORM_TYPE":
                    form_type_field = field
                    break

            if form_type_field is None:
                # §5.4 step 3.f: ignore forms without FORM_TYPE field
                log.debug(
                    _(
                        "Form without FORM_TYPE field in caps for {jid}, ignoring form"
                    ).format(jid=from_jid.full())
                )
                continue

            if len(form_type_field.values) != 1:
                log.warning(
                    _(
                        "FORM_TYPE field has multiple values in caps for {jid}, ignoring"
                        " form"
                    ).format(jid=from_jid.full())
                )
                continue

            seen_form_types.add(form_type)

        return True


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

    def __init__(self, plugin_parent: XEP_0115) -> None:
        self.plugin_parent = plugin_parent

    @property
    def client(self) -> SatXMPPEntity:
        return self.parent  # type: ignore[return-value]

    def connectionInitialized(self) -> None:
        assert self.xmlstream is not None
        self.xmlstream.addObserver(
            CAPABILITY_UPDATE, self.plugin_parent._update, client=self.client
        )

    def getDiscoInfo(
        self,
        requestor: SatXMPPEntity,
        target: SatXMPPEntity,
        nodeIdentifier: str = "",
    ) -> list[disco.DiscoFeature | disco.DiscoIdentity | disco.DiscoExtension]:
        """
        Handle disco#info requests, including caps node queries.

        When nodeIdentifier is in the format 'node#ver' (e.g.,
        'http://libervia.org#abc123'), this is a caps node query per XEP-0115 §6.2
        (Discovering Capabilities). We return the cached disco info for that verification
        string.

        @param requestor: The entity that sent the request.
        @param target: The entity the request was sent to.
        @param nodeIdentifier: The optional node being queried.
        @return: List of disco items (features, identities, extensions).
        """
        # Check if this is a caps node query (format: node#ver)
        if nodeIdentifier and "#" in nodeIdentifier:
            parts = nodeIdentifier.rsplit("#", 1)
            if len(parts) == 2:
                caps_node, cap_ver = parts
                # Only respond if this matches our caps node
                if (
                    caps_node == C.APP_URL
                    and cap_ver in self.plugin_parent.host.memory.disco.hashes
                ):
                    disco_infos = self.plugin_parent.host.memory.disco.hashes[cap_ver]
                    # Return all items from the cached disco info
                    return list(disco_infos)

        # For non-caps queries, return our caps features
        return [
            disco.DiscoFeature(NS_ENTITY_CAPABILITY),
            disco.DiscoFeature(NS_CAPS_OPTIMIZE),
        ]

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