#!/usr/bin/env python3

# Libervia plugin for Ad-Hoc Commands (XEP-0050)
# 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 collections.abc import Callable, Coroutine
from enum import Enum, StrEnum, auto
from typing import Any, NamedTuple, cast
import importlib

from pydantic import BaseModel, Field, ValidationInfo, field_validator
from pydantic import field_serializer
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber.error import StanzaError
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
from tx_xmpp import data_form, disco, itx_xmpp
from zope.interface import implementer

from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPClient, SatXMPPEntity
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
from libervia.backend.memory.memory import SessionData, Sessions
from libervia.backend.models.types import JIDType
from libervia.backend.plugins.plugin_xep_0004 import DataForm, ListSingle, Option
from libervia.backend.tools import utils, xml_tools
from libervia.backend.tools.common import data_format

log = getLogger(__name__)

IQ_SET = '/iq[@type="set"]'
NS_COMMANDS = "http://jabber.org/protocol/commands"
ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list")
ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node")
CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]'

SHOWS = {
    "default": _("Online"),
    "away": _("Away"),
    "chat": _("Free for chat"),
    "dnd": _("Do not disturb"),
    "xa": _("Left"),
    "disconnect": _("Disconnect"),
}

PLUGIN_INFO = {
    C.PI_NAME: "Ad-Hoc Commands",
    C.PI_IMPORT_NAME: "XEP-0050",
    C.PI_MODES: C.PLUG_MODE_BOTH,
    C.PI_TYPE: "XEP",
    C.PI_PROTOCOLS: ["XEP-0050"],
    C.PI_MAIN: "XEP_0050",
    C.PI_HANDLER: "yes",
    C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands"""),
}

ALLOWED_MAGICS = {C.ENTITY_ALL, C.ENTITY_PROFILE_BARE, C.ENTITY_ADMINS}


class Status(StrEnum):
    EXECUTING = auto()
    COMPLETED = auto()
    CANCELED = auto()


class Action(StrEnum):
    EXECUTE = auto()
    CANCEL = auto()
    NEXT = auto()
    PREV = auto()


class NoteType(StrEnum):
    INFO = auto()
    WARN = auto()
    ERROR = auto()


class Error(tuple[str, str | None], Enum):
    """Errors from XEP-0050 §4.4 Table 5"""

    MALFORMED_ACTION = ("bad-request", "malformed-action")
    BAD_ACTION = ("bad-request", "bad-action")
    BAD_LOCALE = ("bad-request", "bad-locale")
    BAD_PAYLOAD = ("bad-request", "bad-payload")
    BAD_SESSIONID = ("bad-request", "bad-sessionid")
    SESSION_EXPIRED = ("not-allowed", "session-expired")
    FORBIDDEN = ("forbidden", None)
    ITEM_NOT_FOUND = ("item-not-found", None)
    FEATURE_NOT_IMPLEMENTED = ("feature-not-implemented", None)
    INTERNAL = ("internal-server-error", None)


class AdHocError(Exception):
    def __init__(self, error_const, text=None, lang=None):
        """Error to be used from callback
        @param error_const: one of Error
        """
        assert error_const in Error
        self.callback_error = error_const
        self.text = text
        self.lang = lang


class Note(BaseModel):
    type: NoteType = NoteType.INFO
    text: str


class AdHocCallbackData(BaseModel):
    """Pydantic model for ad-hoc command callback data.

    This model represents the data structure used in callback functions to return
    information about command execution results.
    """

    # Note: While XEP-0050 theoretically allows using something other than a form as
    #   payload (XEP-0050 §1.3 #prerequisites), forms are always used in practice.
    #   Therefore, only forms are currently supported. However, in the future, a "payload:
    #   DomishElementType|None" field could be added, which would be mutually exclusive
    #   with "form".
    form: DataForm | None = Field(
        default=None, description="The form element which will be used as payload."
    )
    status: Status = Field(description="Current status of the command execution")
    actions: list[Action] = Field(
        default_factory=lambda: list([Action.EXECUTE]),
        description=(
            "List of allowed actions (see Action enum). First action is the default one. "
            "Default to EXECUTE"
        ),
    )
    notes: list[Note] = Field(
        default_factory=list,
        description="Command status notes (cf. XEP-0050 §4.3).",
    )


class PageData(NamedTuple):
    """Information about the current page of an ad-hoc command.

    @param iq_elt: The original IQ request element containing the command element and its
        context.
    @param form: The parsed form which has been submitted by the user. The form is parsed
        with its sources specified, meaning that field types should be set correctly.
        In the rare case when there is no form (may happen for first page if form is
        specified in ``AdHocCommand``), an empty form will be used.
    @param requestor: Bare JID of the entity requesting the command execution.
    @param session_data: A dictionary persisting for the whole command session that can be
        used by the callback function to store any kind of data.
    @param action: The action being performed as defined in the Action enum.
    @param node: The node identifier of the command being executed.
    @param idx: The index of the current page in the command sequence.
        The index is automatically adjusted accorded to the action, it can be used by the
        callback to determine which page to show.
    """

    iq_elt: domish.Element
    form: DataForm
    requestor: jid.JID
    session_data: SessionData
    action: Action
    node: str
    idx: int


AdHocCallback = Callable[
    [SatXMPPEntity, PageData],
    Coroutine[None, None, AdHocCallbackData] | AdHocCallbackData,
]


class AdHocCommand(BaseModel):
    """Pydantic model for ad-hoc command parameters.

    This model represents the parameters used to create an AdHocCommand instance.
    """

    callback: AdHocCallback = Field(
        description=(
            "The callback function to execute when command is invoked.  The "
            'import string "path.to.module.function_to_call" can be used.\n\n'
            "The callback takes the following parameters:\n"
            "- client (SatXMPPEntity): The XMPP client instance\n"
            "- page_data (PageData): Data on current page of the command.\n"
            "The callback must return an AdHocCallbackData instance."
        )
    )
    label: str = Field(description="Label for the command displayed in UI")
    node: str = Field(
        default="",
        # We need the validator to set a value based on label if "node" is not specified.
        validate_default=True,
        description="Unique identifier for this command",
    )
    form: DataForm | None = Field(
        default=None,
        description=(
            "Form used for the first page of the command. If not set, the callback must "
            "handle the initial page, which may include a form, or return results "
            "directly."
        ),
    )
    features: list[str] = Field(
        default_factory=lambda: list([data_form.NS_X_DATA]),
        description="List of features supported by this command",
    )
    timeout: int = Field(default=30, description="Session timeout in seconds")
    allowed_jids: set[JIDType] = Field(
        default_factory=set,
        description="List of JIDs that are allowed to use this command",
    )
    allowed_groups: list[str] = Field(
        default_factory=list,
        description="List of roster groups that are allowed to use this command",
    )
    allowed_magics: list[str] = Field(
        default_factory=lambda: list([C.ENTITY_PROFILE_BARE]),
        description=(
            "List of magic identifiers for special access rules. Possible values include:"
            " C.ENTITY_ALL (allow everybody), C.ENTITY_PROFILE_BARE (allow only the jid "
            "of the profile), C.ENTITY_ADMINS (any administrator user)"
        ),
    )
    forbidden_jids: list[JIDType] = Field(
        default_factory=list,
        description="List of JIDs that are forbidden from using this command",
    )
    forbidden_groups: list[str] = Field(
        default_factory=list,
        description="List of roster groups that are forbidden from using this command",
    )

    @staticmethod
    def get_node_from_label(label: str) -> str:
        """Generate a node from the label."""
        label = label.strip()
        assert label
        node = label.lower().replace(" ", "_")
        return node

    @field_validator("label", mode="after")
    @classmethod
    def check_label_not_empty(cls, value: str) -> str:
        """Check that "label" is not empty.

        @param value: value to check.
        """
        if not value.strip():
            raise ValueError('"label" can\'t be empty.')
        return value

    @field_validator("node", mode="after")
    @classmethod
    def set_default_node(cls, value: str, info: ValidationInfo) -> str:
        """Set a default node if it's empty.

        @param value: value to check.
        """
        if not value.strip():
            return cls.get_node_from_label(info.data["label"])
        return value

    @field_validator("form", mode="after")
    @classmethod
    def ensure_form_type(cls, form: DataForm | None) -> DataForm | None:
        if form is not None and form.type != "form":
            raise ValueError(
                'Only "form" type is allowed for the data form at "form" field.'
            )
        return form

    @field_validator("allowed_magics", mode="after")
    @classmethod
    def check_allowed_magics(cls, value: str) -> str:
        """Check that "allowed_magics" is a valid value.

        @param value: value to check.
        """
        if not ALLOWED_MAGICS.issuperset(value):
            raise ValueError(
                f'"allowed_magics" must be one of {", ".join(ALLOWED_MAGICS)}.'
            )

        return value

    # Both following methods are inspired from
    # https://github.com/pydantic/pydantic/discussions/3911
    # Thanks!
    @field_serializer("callback")
    def serialize_callback(self, callback: Callable) -> str:
        try:
            module_path = callback.__module__
            function_name = callback.__name__
        except AttributeError:
            raise ValueError(
                f"{callback} doesn't have a __module__ or __name__ attribute."
            )
        return module_path + "." + function_name

    @field_validator("callback", mode="before", json_schema_input_type=str)
    @classmethod
    def import_callback(cls, value: Any) -> Callable:
        """Accept import string or callable.

        @param value: May be a callable or a string representation of the callable (e.g.,
            'module.function')
        @return: The imported callable.
        """
        if isinstance(value, str):
            module_name, function_name = value.rsplit(".", 1)
            module = importlib.import_module(module_name)
            return getattr(module, function_name)
        elif isinstance(value, Callable):
            return value
        else:
            raise ValueError('"str" or "Callable are expected for "callback".')


@implementer(itx_xmpp.IDisco)
class AdHocCommandHandler(XMPPHandler):
    """Implementation of an Ad-Hoc Command handler for XMPP.

    This class handles the execution of ad-hoc commands as defined in XEP-0050.
    It manages command sessions, authorization checks, and communication with clients.
    """

    def __init__(self, command: AdHocCommand) -> None:
        """Initialize the AdHocCommand handler.

        @param command: The AdHocCommand instance containing all command configuration
        """
        XMPPHandler.__init__(self)
        self.command = command
        self.sessions = Sessions(timeout=command.timeout)

    @property
    def client(self) -> SatXMPPEntity:
        """Get the client associated with this command handler.

        @return: The XMPP client instance
        """
        assert self.parent is not None
        return self.parent

    def get_name(self, xml_lang: str | None = None) -> str:
        """Get the name of this command for display purposes.

        @param xml_lang: Language code for localization (unused)
        @return: The label of this command
        """
        return self.command.label

    def is_authorised(self, requestor: jid.JID) -> bool:
        """Check if a requestor is authorized to use this command.

        @param requestor: JID of the entity requesting access
        @return: True if authorized, False otherwise
        @raise exceptions.InternalError: A permission with groups is used with a
            component.
        """
        if C.ENTITY_ALL in self.command.allowed_magics:
            return True
        forbidden = set(self.command.forbidden_jids)
        if self.command.forbidden_groups:
            if not isinstance(self.client, SatXMPPClient):
                raise exceptions.InternalError(
                    "\"forbidden_groups\" can't be used which components, as they don't "
                    "have roster."
                )
            for group in self.command.forbidden_groups:
                forbidden.update(self.client.roster.get_jids_from_group(group))
        if requestor.userhostJID() in forbidden:
            return False
        allowed = self.command.allowed_jids
        if self.command.allowed_groups:
            if not isinstance(self.client, SatXMPPClient):
                raise exceptions.InternalError(
                    "\"allowed_groups\" can't be used which components, as they don't "
                    "have roster."
                )
            for group in self.command.allowed_groups:
                try:
                    allowed.update(self.client.roster.get_jids_from_group(group))
                except exceptions.UnknownGroupError:
                    log.warning(
                        _(
                            "The groups [{group}] is unknown for profile [{profile}])"
                        ).format(group=group, profile=self.client.profile)
                    )
        if requestor.userhostJID() in allowed:
            return True
        return False

    def getDiscoInfo(
        self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = ""
    ) -> list[disco.DiscoFeature]:
        """Get disco info for this command.

        @param requestor: JID of the entity requesting information
        @param target: Target JID
        @param nodeIdentifier: Node identifier (unused)
        @return: List of disco features supported by this command
        """
        if (
            nodeIdentifier != NS_COMMANDS
        ):  # FIXME: we should manage other disco nodes here
            return []
        # identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE] # FIXME
        return [disco.DiscoFeature(NS_COMMANDS)] + [
            disco.DiscoFeature(feature) for feature in self.command.features
        ]

    def getDiscoItems(
        self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = ""
    ) -> list[disco.DiscoItem]:
        """Get disco items for this command.

        @param requestor: JID of the entity requesting information
        @param target: Target JID
        @param nodeIdentifier: Node identifier (unused)
        @return: Empty list as no items are supported
        """
        return []

    async def _send_answer(
        self,
        callback_data: AdHocCallbackData,
        session_id: str,
        session_data: dict,
        iq_elt: domish.Element,
    ) -> None:
        """Send result of the command.

        @param callback_data: Data returned by the callback.
        @param session_id: current session id
        @param request: original request (domish.Element)
        """
        status = callback_data.status
        result = domish.Element((None, "iq"))
        result["type"] = "result"
        result["id"] = iq_elt["id"]
        result["to"] = iq_elt["from"]
        command_elt = result.addElement((NS_COMMANDS, "command"))
        command_elt["sessionid"] = session_id
        command_elt["node"] = self.command.node
        command_elt["status"] = status

        if status != Status.CANCELED:
            if status != Status.COMPLETED:
                actions = callback_data.actions
                assert actions
                actions_elt = command_elt.addElement("actions")
                actions_elt["execute"] = actions[0]
                for action in actions:
                    actions_elt.addElement(action)

            for note in callback_data.notes:
                note_elt = command_elt.addElement("note", content=note.text)
                note_elt["type"] = note.type

            if callback_data.form is not None:
                command_elt.addChild(callback_data.form.to_element())

        await self.client.a_send(result)
        if status in (Status.COMPLETED, Status.CANCELED):
            del self.sessions[session_id]
        else:
            # We store form used in pages so we can get submitted forms fields types.
            session_data["forms"][session_data["page_idx"]] = callback_data.form

    async def _send_error(
        self,
        error_constant: Error,
        session_id: str | None,
        request: domish.Element,
        text: str | None = None,
        lang: str | None = None,
    ) -> None:
        """Send error stanza.

        @param error_constant: Error tuple.
        @param session_id: Session ID.
        @param request: Original request.
        """
        xmpp_condition, cmd_condition = error_constant
        iq_elt = StanzaError(xmpp_condition, text=text, textLang=lang).toResponse(request)
        if cmd_condition:
            error_elt = next(iq_elt.elements(None, "error"))
            error_elt.addElement(cmd_condition, NS_COMMANDS)
        await self.client.a_send(iq_elt)
        if session_id is not None:
            del self.sessions[session_id]

    async def on_request(
        self,
        command_elt: domish.Element,
        requestor: jid.JID,
        action: Action,
        session_id: str | None,
    ) -> None:
        """Handle an incoming command request.

        @param command_elt: The command element from the request
        @param requestor: Full JID of the entity making the request
        @param action: The action to perform (execute, cancel, etc.)
        @param session_id: Session ID if continuing an existing session
        @return: Deferred for handling the response
        """
        iq_elt = command_elt.parent
        assert iq_elt is not None

        if not self.is_authorised(requestor):
            return await self._send_error(Error.FORBIDDEN, session_id, iq_elt)

        if session_id:
            try:
                session_data_global = self.sessions[session_id]
            except KeyError:
                return await self._send_error(Error.SESSION_EXPIRED, session_id, iq_elt)
            else:
                session_data_private = session_data_global["private"]
                session_data_public = session_data_global["public"]
            if session_data_private["requestor"] != requestor:
                return await self._send_error(Error.FORBIDDEN, session_id, iq_elt)
            forms = session_data_private["forms"]
            if action == Action.PREV:
                if session_data_private["page_idx"] > 0:
                    session_data_private["page_idx"] -= 1
                    if (
                        session_data_private["page_idx"] == 0
                        and self.command.form is not None
                    ):
                        # we're back on the first page with original form, we send id
                        # again.
                        await self._send_answer(
                            AdHocCallbackData(
                                form=self.command.form, status=Status.EXECUTING
                            ),
                            session_id,
                            session_data_private,
                            iq_elt,
                        )
                        return
            else:
                session_data_private["page_idx"] += 1
            page_idx = session_data_private["page_idx"]
        else:
            session_id, session_data_global = self.sessions.new_session()
            # We use 2 dict: one for private date use to keep track of internal things
            # such as page index, that we expose to callback through ``PageData``, and the
            # public one which is exposed to callback through ``PageData.session_data``.
            session_data_private = session_data_global["private"] = {}
            session_data_public = session_data_global["public"] = {}
            session_data_private["requestor"] = requestor
            page_idx = session_data_private["page_idx"] = 0
            # Maps page_idx to corresponding form.
            forms = session_data_private["forms"] = {}
            if action != Action.EXECUTE:
                log.warning(
                    "Unexpected action on first page request of ad-hoc command: "
                    f"{action!r}."
                )
                await self._send_error(Error.BAD_ACTION, session_id, iq_elt)
                return
            # This is the first request, if a initial form is specified, we return it
            # directly.
            if self.command.form is not None:
                await self._send_answer(
                    AdHocCallbackData(form=self.command.form, status=Status.EXECUTING),
                    session_id,
                    session_data_private,
                    iq_elt,
                )
                return

        if action == Action.CANCEL:
            ret_data = AdHocCallbackData(form=None, status=Status.CANCELED)
        else:
            if page_idx == 0:
                # We use an empty DataForm instead of None here because we want PageData
                # to always have a form, to avoid having to test its existence each time.
                # The case when there is no form should be rare: where we are on the first
                # page and the form is not already specified with ``AdHocCommand``.
                form = DataForm()
            else:
                last_form = forms[page_idx - 1]
                form = DataForm.from_element(command_elt, source_form=last_form)
            page_data = PageData(
                iq_elt=iq_elt,
                form=form,
                requestor=requestor.userhostJID(),
                session_data=session_data_public,
                action=action,
                node=self.command.node,
                idx=page_idx,
            )
            try:
                ret_data = await utils.maybe_async(
                    self.command.callback(self.client, page_data)
                )
            except AdHocError as e:
                await self._send_error(
                    e.callback_error, session_id, iq_elt, text=e.text, lang=e.lang
                )
                return
            except Exception as e:
                log.exception(f"Unexpected error while handling request.")
                await self._send_error(
                    Error.INTERNAL, session_id, iq_elt, text=f"Internal Error: {e}"
                )
                return

        await self._send_answer(ret_data, session_id, session_data_private, iq_elt)


class XEP_0050:

    def __init__(self, host):
        log.info(_("plugin XEP-0050 initialization"))
        self.host = host
        self.requesting = Sessions()
        host.bridge.add_method(
            "ad_hoc_run",
            ".plugin",
            in_sign="sss",
            out_sign="s",
            method=self._run,
            async_=True,
        )
        host.bridge.add_method(
            "ad_hoc_list",
            ".plugin",
            in_sign="ss",
            out_sign="s",
            method=self._list_ui,
            async_=True,
        )
        host.bridge.add_method(
            "ad_hoc_sequence",
            ".plugin",
            in_sign="ssss",
            out_sign="s",
            method=self._sequence,
            async_=True,
        )
        self.__requesting_id = host.register_callback(
            self._requesting_entity, with_data=True
        )
        host.import_menu(
            (D_("Service"), D_("Commands")),
            self._commands_menu,
            security_limit=2,
            help_string=D_("Execute ad-hoc commands"),
        )
        host.register_namespace("commands", NS_COMMANDS)

    def get_handler(self, client):
        return XEP_0050_handler(self)

    def profile_connected(self, client):
        # map from node to AdHocCommandHandler instance
        client._XEP_0050_commands = {}
        if not client.is_component:
            self.register_ad_hoc_command(
                client,
                AdHocCommand(
                    callback=self._status_callback,
                    label=D_("Status"),
                    form=DataForm(
                        title=D_("Status Selection"),
                        fields=[
                            ListSingle(
                                var="show",
                                options=[
                                    Option(value=value, label=label)
                                    for value, label in list(SHOWS.items())
                                ],
                                required=True,
                            )
                        ],
                    ),
                ),
            )

    def do(
        self,
        client: SatXMPPEntity,
        entity: jid.JID,
        node: str,
        action: Action = Action.EXECUTE,
        session_id: str | None = None,
        form_values: dict[str, Any] | None = None,
        timeout: int = 30,
    ) -> defer.Deferred[domish.Element]:
        """Do an Ad-Hoc Command.

        @param entity: Entity which will execute the command.
        @param node: Node of the command.
        @param action: One of Action.
        @param session_id: ID of the ad-hoc session.
            None if no session is involved.
        @param form_values: Values to use to create command form.
            Values will be passed to data_form.Form.makeFields.
        @return: IQ result element.
        """
        iq_elt = client.IQ(timeout=timeout)
        iq_elt["to"] = entity.full()
        command_elt = iq_elt.addElement((NS_COMMANDS, "command"))
        command_elt["node"] = node
        command_elt["action"] = action
        if session_id is not None:
            command_elt["sessionid"] = session_id

        if form_values:
            # We add the XMLUI result to the command payload
            form = data_form.Form("submit")
            form.makeFields(form_values)
            command_elt.addChild(form.toElement())
        d = iq_elt.send()
        return d

    def get_command_elt(self, iq_elt):
        try:
            return next(iq_elt.elements(NS_COMMANDS, "command"))
        except StopIteration:
            raise exceptions.NotFound(_("Missing command element"))

    def ad_hoc_error(self, error_type: Error):
        """Shortcut to raise an AdHocError

        @param error_type(unicode): one of Error
        """
        raise AdHocError(error_type)

    def _items_2_xmlui(
        self, items: list[disco.DiscoItem], no_instructions: bool
    ) -> xml_tools.XMLUI:
        """Convert discovery items to XMLUI dialog

        This method transforms a list of discovery items into an XMLUI form
        that can be presented to the user for selecting a command.

        @param items: List of discovery items to convert
        @param no_instructions: If True, don't add instructions widget
        @return: XMLUI form representing the command selection interface
        """
        # TODO: manage items on different jids
        form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)

        if not no_instructions:
            form_ui.addText(_("Please select a command"), "instructions")

        options = [(item.nodeIdentifier, item.name) for item in items]
        form_ui.addList("node", options)
        return form_ui

    def _get_data_lvl(self, type_):
        """Return the constant corresponding to <note/> type attribute value

        @param type_: note type (see XEP-0050 §4.3)
        @return: a C.XMLUI_DATA_LVL_* constant
        """
        if type_ == "error":
            return C.XMLUI_DATA_LVL_ERROR
        elif type_ == "warn":
            return C.XMLUI_DATA_LVL_WARNING
        else:
            if type_ != "info":
                log.warning(_("Invalid note type [%s], using info") % type_)
            return C.XMLUI_DATA_LVL_INFO

    def _merge_notes(self, notes):
        """Merge notes with level prefix (e.g. "ERROR: the message")

        @param notes (list): list of tuple (level, message)
        @return: list of messages
        """
        lvl_map = {
            C.XMLUI_DATA_LVL_INFO: "",
            C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"),
            C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR"),
        }
        return ["%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes]

    def parse_command_answer(self, iq_elt):
        command_elt = self.get_command_elt(iq_elt)
        data = {}
        data["status"] = command_elt.getAttribute("status", Status.EXECUTING)
        data["session_id"] = command_elt.getAttribute("sessionid")
        data["notes"] = notes = []
        for note_elt in command_elt.elements(NS_COMMANDS, "note"):
            notes.append(
                (
                    self._get_data_lvl(note_elt.getAttribute("type", "info")),
                    str(note_elt),
                )
            )

        return command_elt, data

    def _commands_answer_2_xmlui(self, iq_elt, session_id, session_data):
        """Convert command answer to an ui for frontend

        @param iq_elt: command result
        @param session_id: id of the session used with the frontend
        @param profile_key: %(doc_profile_key)s
        """
        command_elt, answer_data = self.parse_command_answer(iq_elt)
        status = answer_data["status"]
        if status in [Status.COMPLETED, Status.CANCELED]:
            # the command session is finished, we purge our session
            del self.requesting[session_id]
            if status == Status.COMPLETED:
                session_id = None
            else:
                return None
        remote_session_id = answer_data["session_id"]
        if remote_session_id:
            session_data["remote_id"] = remote_session_id
        notes = answer_data["notes"]
        for data_elt in command_elt.elements(data_form.NS_X_DATA, "x"):
            if data_elt["type"] in ("form", "result"):
                break
        else:
            # no matching data element found
            if status != Status.COMPLETED:
                log.warning(
                    _("No known payload found in ad-hoc command result, aborting")
                )
                del self.requesting[session_id]
                return xml_tools.XMLUI(
                    C.XMLUI_DIALOG,
                    dialog_opt={
                        C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
                        C.XMLUI_DATA_MESS: _("No payload found"),
                        C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR,
                    },
                )
            if not notes:
                # the status is completed, and we have no note to show
                return None

            # if we have only one note, we show a dialog with the level of the note
            # if we have more, we show a dialog with "info" level, and all notes merged
            dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO
            return xml_tools.XMLUI(
                C.XMLUI_DIALOG,
                dialog_opt={
                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
                    C.XMLUI_DATA_MESS: "\n".join(self._merge_notes(notes)),
                    C.XMLUI_DATA_LVL: dlg_level,
                },
                session_id=session_id,
            )

        if session_id is None:
            xmlui = xml_tools.data_form_elt_result_2_xmlui(data_elt)
            if notes:
                for level, note in notes:
                    if level != "info":
                        note = f"[{level}] {note}"
                    xmlui.add_widget("text", note)
            return xmlui

        form = data_form.Form.fromElement(data_elt)
        # we add any present note to the instructions
        form.instructions.extend(self._merge_notes(notes))
        return xml_tools.data_form_2_xmlui(
            form, self.__requesting_id, session_id=session_id
        )

    def _requesting_entity(self, data, profile):
        def serialise(ret_data):
            if "xmlui" in ret_data:
                ret_data["xmlui"] = ret_data["xmlui"].toXml()
            return ret_data

        d = defer.ensureDeferred(self.requesting_entity(data, profile))
        d.addCallback(serialise)
        return d

    async def requesting_entity(self, data, profile):
        """Request and entity and create XMLUI accordingly.

        @param data: data returned by previous XMLUI (first one must come from
                     self._commands_menu)
        @param profile: %(doc_profile)s
        @return: callback dict result (with "xmlui" corresponding to the answering
                 dialog, or empty if it's finished without error)
        """
        if C.bool(data.get("cancelled", C.BOOL_FALSE)):
            return {}
        data_form_values = xml_tools.xmlui_result_2_data_form_result(data)
        client = self.host.get_client(profile)
        # TODO: cancel, prev and next are not managed
        # TODO: managed answerer errors
        # TODO: manage nodes with a non data form payload
        if "session_id" not in data:
            # we just had the jid, we now request it for the available commands
            session_id, session_data = self.requesting.new_session(profile=client.profile)
            entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX + "jid"])
            session_data["jid"] = entity
            xmlui = await self.list_ui(client, entity)
            # we need to keep track of the session
            xmlui.session_id = session_id
            return {"xmlui": xmlui}

        else:
            # we have started a several forms sessions
            try:
                session_data = self.requesting.profile_get(
                    data["session_id"], client.profile
                )
            except KeyError:
                log.warning("session id doesn't exist, session has probably expired")
                # TODO: send error dialog
                return {}
            session_id = data["session_id"]
            entity = session_data["jid"]
            try:
                session_data["node"]
                # node has already been received
            except KeyError:
                # it's the first time we know the node, we save it in session data
                session_data["node"] = data_form_values.pop("node")

            # remote_id is the XEP_0050 sessionid used by answering command
            # while session_id is our own session id used with the frontend
            remote_id = session_data.get("remote_id")

            # we request execute node's command
            iq_result_elt = await self.do(
                client,
                entity,
                session_data["node"],
                action=Action.EXECUTE,
                session_id=remote_id,
                form_values=data_form_values,
            )
            xmlui = self._commands_answer_2_xmlui(iq_result_elt, session_id, session_data)
            return {"xmlui": xmlui} if xmlui is not None else {}

    def _commands_menu(self, menu_data, profile):
        """First XMLUI activated by menu: ask for target jid

        @param profile: %(doc_profile)s
        """
        form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
        form_ui.addText(_("Please enter target jid"), "instructions")
        form_ui.change_container("pairs")
        form_ui.addLabel("jid")
        form_ui.addString("jid", value=self.host.get_client(profile).jid.host)
        return {"xmlui": form_ui.toXml()}

    def _status_callback(
        self,
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Ad-hoc command used to change the "show" part of status.

        @param client: Libervia client instance.
        @param page_data: Ad-Hoc page data.
        @return: Command results.

        @raise AdHocError: Something went wrong.
        """
        show = page_data.form.get_field("show", ListSingle).value
        if show not in SHOWS:
            raise AdHocError(Error.BAD_PAYLOAD, text=f"Invalid value: {show=}")
        if show == "disconnect":
            self.host.disconnect(client.profile)
        else:
            self.host.presence_set(show=show, profile_key=client.profile)

        return AdHocCallbackData(
            status=Status.COMPLETED, notes=[Note(text=_("Status updated"))]
        )

    def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE):
        client = self.host.get_client(profile_key)
        service_jid = jid.JID(service_jid_s) if service_jid_s else None
        d = defer.ensureDeferred(self.run(client, service_jid, node or None))
        d.addCallback(lambda xmlui: xmlui.toXml())
        return d

    async def run(
        self,
        client: SatXMPPEntity,
        service_jid: jid.JID | None = None,
        node: str | None = None,
    ) -> xml_tools.XMLUI:
        """Run an ad-hoc command

        @param client: XMPP entity client
        @param service_jid: jid of the ad-hoc service
            None to use profile's server
        @param node: node of the ad-hoc commnad
            None to get initial list
        @return: command page XMLUI
        """
        if service_jid is None:
            service_jid = jid.JID(client.jid.host)
        session_id, session_data = self.requesting.new_session(profile=client.profile)
        session_data["jid"] = service_jid
        if node is None:
            xmlui = await self.list_ui(client, service_jid)
        else:
            session_data["node"] = node
            cb_data = await self.requesting_entity(
                {"session_id": session_id}, client.profile
            )
            xmlui = cb_data["xmlui"]

        xmlui.session_id = session_id
        return xmlui

    def list_commands(
        self, client: SatXMPPEntity, to_jid: jid.JID | None
    ) -> defer.Deferred[disco.DiscoItems]:
        """Request available commands

        @param to_jid: the entity answering the commands
            None to use profile's server
        @return: found commands
        """
        d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS)
        return d

    def _list_ui(self, to_jid_s, profile_key):
        client = self.host.get_client(profile_key)
        to_jid = jid.JID(to_jid_s) if to_jid_s else None
        d = self.list_ui(client, to_jid, no_instructions=True)
        d.addCallback(lambda xmlui: xmlui.toXml())
        return d

    def list_ui(
        self, client: SatXMPPEntity, to_jid: jid.JID | None, no_instructions: bool = False
    ) -> defer.Deferred[xml_tools.XMLUI]:
        """Request available commands and generate XMLUI

        @param to_jid: the entity answering the commands
            None to use profile's server
        @param no_instructions: if True, don't add instructions widget
        @return: UI with the commands
        """
        d = self.list_commands(client, to_jid)
        d.addCallback(self._items_2_xmlui, no_instructions)
        return d

    def _sequence(self, sequence, node, service_jid_s="", profile_key=C.PROF_KEY_NONE):
        sequence = data_format.deserialise(sequence, type_check=list)
        client = self.host.get_client(profile_key)
        service_jid = jid.JID(service_jid_s) if service_jid_s else None
        d = defer.ensureDeferred(self.sequence(client, sequence, node, service_jid))
        d.addCallback(lambda data: data_format.serialise(data))
        return d

    async def sequence(
        self,
        client: SatXMPPEntity,
        sequence: list[dict],
        node: str,
        service_jid: jid.JID | None = None,
    ) -> dict:
        """Send a series of data to an ad-hoc service

        @param sequence: list of values to send
            value are specified by a dict mapping var name to value.
        @param node: node of the ad-hoc commnad
        @param service_jid: jid of the ad-hoc service
            None to use profile's server
        @return: data received in final answer
        """
        assert sequence
        answer_data = None
        if service_jid is None:
            service_jid = jid.JID(client.jid.host)

        session_id = None

        for data_to_send in sequence:
            iq_result_elt = await self.do(
                client,
                service_jid,
                node,
                session_id=session_id,
                form_values=data_to_send,
            )
            __, answer_data = self.parse_command_answer(iq_result_elt)
            session_id = answer_data.pop("session_id")

        assert answer_data is not None

        return answer_data

    def register_ad_hoc_command(
        self, client: SatXMPPEntity, command: AdHocCommand
    ) -> str:
        """Register an ad-hoc command using an AdHocCommand instance.

        @param client: The XMPP client to register the command for
        @param command: The AdHocCommand instance containing all command configuration
        @return: node of the added command, useful to remove the command later
        """
        command_handlers = client._XEP_0050_commands

        if command.node in command_handlers:
            raise exceptions.ConflictError(
                f"There is already a known node with the name {command.node!r}."
            )

        # TODO: manage newly created/removed profiles
        if C.ENTITY_PROFILE_BARE in command.allowed_magics:
            command.allowed_jids.add(client.jid.userhostJID())
        # TODO: manage dynamic addition/removal of admin status once possible
        if C.ENTITY_ADMINS in command.allowed_magics:
            command.allowed_jids.update(self.host.memory.admin_jids)

        # Create the handler using the command parameters
        command_handler = AdHocCommandHandler(command)
        command_handler.setHandlerParent(client)
        command_handlers[command.node] = command_handler
        return command.node

    def add_ad_hoc_command(
        self,
        client: SatXMPPEntity,
        callback: AdHocCallback,
        label: str,
        node: str | None = None,
        features: list[str] | None = None,
        timeout: int = 600,
        allowed_jids: list[jid.JID] | None = None,
        allowed_groups: list[str] | None = None,
        allowed_magics: list[str] | None = None,
        forbidden_jids: list[jid.JID] | None = None,
        forbidden_groups: list[str] | None = None,
    ) -> str:
        """Add an ad-hoc command for the current profile

        @param callback: method associated with this ad-hoc command which return the
            payload data (see AdHocCommand._sendAnswer), can return a deferred
        @param label: label associated with this command on the main menu
        @param node: disco item node associated with this command. None to use
            autogenerated node
        @param features: features associated with the payload (list of strings), usualy
            data form
        @param timeout: delay between two requests before canceling the session (in
            seconds)
        @param allowed_jids: list of allowed entities
        @param allowed_groups: list of allowed roster groups
        @param allowed_magics: list of allowed magic keys, can be:
            C.ENTITY_ALL: allow everybody
            C.ENTITY_PROFILE_BARE: allow only the jid of the profile
            C.ENTITY_ADMINS: any administrator user
        @param forbidden_jids: black list of entities which can't access this command
        @param forbidden_groups: black list of groups which can't access this command
        @return: node of the added command, useful to remove the command later
        """
        kwargs = {
            "callback": callback,
            "label": label,
            "node": node,
            "features": features,
            "timeout": timeout,
            "allowed_jids": allowed_jids,
            "allowed_groups": allowed_groups,
            "allowed_magics": allowed_magics,
            "forbidden_jids": forbidden_jids,
            "forbidden_groups": forbidden_groups,
        }
        kwargs = {k: v for k, v in kwargs.items() if v is not None}
        return self.register_ad_hoc_command(client, AdHocCommand(**kwargs))

    def on_cmd_request(self, iq_elt, client):
        iq_elt.handled = True
        requestor = jid.JID(iq_elt["from"])
        command_elt = next(iq_elt.elements(NS_COMMANDS, "command"))
        action = command_elt.getAttribute("action", Action.EXECUTE)
        node = command_elt.getAttribute("node")
        if not node:
            client.sendError(iq_elt, "bad-request")
            return
        sessionid = command_elt.getAttribute("sessionid")
        command_handlers = client._XEP_0050_commands
        try:
            command = cast(AdHocCommandHandler, command_handlers[node])
        except KeyError:
            client.sendError(iq_elt, "item-not-found")
            return
        defer.ensureDeferred(
            command.on_request(command_elt, requestor, action, sessionid)
        )


@implementer(itx_xmpp.IDisco)
class XEP_0050_handler(XMPPHandler):
    def __init__(self, plugin_parent):
        self.plugin_parent = plugin_parent

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

    def connectionInitialized(self):
        self.xmlstream.addObserver(
            CMD_REQUEST, self.plugin_parent.on_cmd_request, client=self.parent
        )

    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
        identities = []
        if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands:
            # we only add the identity if we have registred commands
            identities.append(ID_CMD_LIST)
        return [disco.DiscoFeature(NS_COMMANDS)] + identities

    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
        ret = []
        if nodeIdentifier == NS_COMMANDS:
            command_handlers: dict[str, AdHocCommandHandler] = (
                self.client._XEP_0050_commands
            )
            for command_handler in list(command_handlers.values()):
                if command_handler.is_authorised(requestor):
                    ret.append(
                        disco.DiscoItem(
                            self.client.jid,
                            command_handler.command.node,
                            command_handler.get_name(),
                        )
                    )  # TODO: manage name language
        return ret
