#!/usr/bin/env python3


# SAT plugin for adding D-Bus to Ad-Hoc Commands
# 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 import OrderedDict
from functools import partial
import os.path
from typing import cast
import uuid

from twisted.internet import defer
from twisted.internet import reactor
from twisted.words.protocols.jabber import jid
from tx_xmpp import data_form

from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
from libervia.backend.plugins.plugin_xep_0004 import DataForm, ListSingle, Option, Text
from libervia.backend.plugins.plugin_xep_0050 import (
    XEP_0050,
    Action,
    AdHocCallbackData,
    AdHocCommand,
    AdHocError,
    Error,
    Note,
    PageData,
    Status,
)

log = getLogger(__name__)


try:
    from lxml import etree
except ImportError:
    etree = None
    log.warning(
        "Missing module lxml, please download/install it from http://lxml.de/ ."
        "Auto D-Bus discovery will be disabled"
    )

try:
    import txdbus
    from txdbus import client as dbus_client
except ImportError:
    txdbus = None
    dbus_client = None
    log.warning(
        "Missing module txdbus, please download/install it, auto D-Bus discovery will be "
        "disabled"
    )


NS_MEDIA_PLAYER = "org.libervia.mediaplayer"
FD_NAME = "org.freedesktop.DBus"
FD_PATH = "/org/freedesktop/DBus"
INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
MPRIS_PREFIX = "org.mpris.MediaPlayer2"
CMD_GO_BACK = "GoBack"
CMD_GO_FWD = "GoFW"
SEEK_OFFSET = 5 * 1000 * 1000
MPRIS_COMMANDS = [
    "org.mpris.MediaPlayer2.Player." + cmd
    for cmd in ("Previous", CMD_GO_BACK, "PlayPause", CMD_GO_FWD, "Next")
]
MPRIS_PATH = "/org/mpris/MediaPlayer2"
MPRIS_PROPERTIES = OrderedDict(
    (
        ("org.mpris.MediaPlayer2", ("Identity",)),
        (
            "org.mpris.MediaPlayer2.Player",
            (
                "Metadata",
                "PlaybackStatus",
                "Volume",
            ),
        ),
    )
)
MPRIS_METADATA_KEY = "Metadata"
MPRIS_METADATA_MAP = {"xesam:title": "Title"}

INTROSPECT_METHOD = "Introspect"
IGNORED_IFACES_START = (
    "org.freedesktop",
    "org.qtproject",
    "org.kde.KMainWindow",
)  # commands in interface starting with these values will be ignored
FLAG_LOOP = "LOOP"

PLUGIN_INFO = {
    C.PI_NAME: "Ad-Hoc Commands - D-Bus",
    C.PI_IMPORT_NAME: "AD_HOC_DBUS",
    C.PI_TYPE: "Misc",
    C.PI_PROTOCOLS: [],
    C.PI_DEPENDENCIES: ["XEP-0050"],
    C.PI_MAIN: "AdHocDBus",
    C.PI_HANDLER: "no",
    C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""),
}


class AdHocDBus:

    def __init__(self, host):
        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
        self.host = host
        if etree is not None:
            host.bridge.add_method(
                "ad_hoc_dbus_add_auto",
                ".plugin",
                in_sign="sasasasasasass",
                out_sign="(sa(sss))",
                method=self._ad_hoc_dbus_add_auto,
                async_=True,
            )
        host.bridge.add_method(
            "ad_hoc_remotes_get",
            ".plugin",
            in_sign="s",
            out_sign="a(sss)",
            method=self._ad_hoc_remotes_get,
            async_=True,
        )
        self._c = cast(XEP_0050, host.plugins["XEP-0050"])
        host.register_namespace("mediaplayer", NS_MEDIA_PLAYER)
        self.session_con = None

    async def profile_connected(self, client):
        if txdbus is not None:
            assert dbus_client is not None
            if self.session_con is None:
                self.session_con = await dbus_client.connect(reactor, "session")
                self.fd_object = await self.session_con.getRemoteObject(FD_NAME, FD_PATH)

            self._c.register_ad_hoc_command(
                client,
                AdHocCommand(
                    callback=self.local_media_cb,
                    label=D_("Media Players"),
                    node=NS_MEDIA_PLAYER,
                    # 6 hours timeout, to avoid breaking remote, in the middle of a movie
                    timeout=60 * 60 * 6,
                ),
            )

    async def _dbus_async_call(self, proxy, method, *args, **kwargs):
        """Call a DBus method asynchronously and return a deferred

        @param proxy: DBus object proxy, as returner by get_object
        @param method: name of the method to call
        @param args: will be transmitted to the method
        @param kwargs: will be transmetted to the method, except for the following poped
            values:
            - interface: name of the interface to use
        @return: a deferred

        """
        return await proxy.callRemote(method, *args, **kwargs)

    async def _dbus_get_property(self, proxy, interface, name):
        return await self._dbus_async_call(
            proxy, "Get", interface, name, interface="org.freedesktop.DBus.Properties"
        )

    async def _dbus_list_names(self):
        return await self.fd_object.callRemote("ListNames")

    async def _dbus_introspect(self, proxy):
        return await self._dbus_async_call(
            proxy, INTROSPECT_METHOD, interface=INTROSPECT_IFACE
        )

    def _accept_method(self, method):
        """Return True if we accept the method for a command
        @param method: etree.Element
        @return: True if the method is acceptable

        """
        if method.xpath(
            "arg[@direction='in']"
        ):  # we don't accept method with argument for the moment
            return False
        return True

    async def _introspect(self, methods, bus_name, proxy):
        log.debug("introspecting path [%s]" % proxy.object_path)
        assert etree is not None
        introspect_xml = await self._dbus_introspect(proxy)
        el = etree.fromstring(introspect_xml)
        for node in el.iterchildren("node", "interface"):
            if node.tag == "node":
                new_path = os.path.join(proxy.object_path, node.get("name"))
                new_proxy = await self.session_con.getRemoteObject(bus_name, new_path)
                await self._introspect(methods, bus_name, new_proxy)
            elif node.tag == "interface":
                name = node.get("name")
                if any(name.startswith(ignored) for ignored in IGNORED_IFACES_START):
                    log.debug("interface [%s] is ignored" % name)
                    continue
                log.debug("introspecting interface [%s]" % name)
                for method in node.iterchildren("method"):
                    if self._accept_method(method):
                        method_name = method.get("name")
                        log.debug("method accepted: [%s]" % method_name)
                        methods.add((proxy.object_path, name, method_name))

    def _ad_hoc_dbus_add_auto(
        self,
        prog_name,
        allowed_jids,
        allowed_groups,
        allowed_magics,
        forbidden_jids,
        forbidden_groups,
        flags,
        profile_key,
    ):
        client = self.host.get_client(profile_key)
        return self.ad_hoc_dbus_add_auto(
            client,
            prog_name,
            allowed_jids,
            allowed_groups,
            allowed_magics,
            forbidden_jids,
            forbidden_groups,
            flags,
        )

    async def ad_hoc_dbus_add_auto(
        self,
        client,
        prog_name,
        allowed_jids=None,
        allowed_groups=None,
        allowed_magics=None,
        forbidden_jids=None,
        forbidden_groups=None,
        flags=None,
    ):
        bus_names = await self._dbus_list_names()
        bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name]
        if not bus_names:
            log.info("Can't find any bus for [%s]" % prog_name)
            return ("", [])
        bus_names.sort()
        for bus_name in bus_names:
            if bus_name.endswith(prog_name):
                break
        else:
            log.info(f"Can't find any command for {prog_name}")
            return ("", [])
        log.info(f"bus name found: {bus_name}")
        proxy = await self.session_con.getRemoteObject(bus_name, "/")
        methods = set()

        await self._introspect(methods, bus_name, proxy)

        if methods:
            self._c.add_ad_hoc_command(
                client,
                partial(self.dbus_callback, methods, bus_name, flags or set()),
                prog_name,
                allowed_jids=allowed_jids,
                allowed_groups=allowed_groups,
                allowed_magics=allowed_magics,
                forbidden_jids=forbidden_jids,
                forbidden_groups=forbidden_groups,
            )

        return (str(bus_name), methods)

    async def dbus_callback(
        self,
        methods: set[tuple[str, str, str]],
        bus_name: str,
        flags: set[str],
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Ad-hoc command for executing D-Bus methods.

        @param methods: List of available D-Bus methods.
        @param bus_name: Name of the D-Bus service.
        @param flags: Command execution flags.
        @param client: Libervia client instance.
        @param page_data: Ad-Hoc page data.
        @return: Command results.

        @raise AdHocError: When bad payload or internal errors occur.
        """
        session_data = page_data.session_data
        names_map = session_data.setdefault("names_map", {})

        if page_data.idx == 0:
            # It's our first request, we ask the desired new status.
            session_data["restart"] = False
            form = DataForm(title=D_("Command selection"))
            options = []
            for path, iface, command in methods:
                label = command.rsplit(".", 1)[-1]
                value = str(uuid.uuid4())
                names_map[value] = (path, iface, command)
                options.append(Option(value=value, label=label))

            form.fields.append(ListSingle(var="command", options=options, required=True))
            session_data["command_form"] = form

            return AdHocCallbackData(form=form, status=Status.EXECUTING)

        else:
            # We should have the form here.
            try:
                command = page_data.form.get_field("command", ListSingle).value
                if not command:
                    raise KeyError
            except KeyError:
                raise AdHocError(Error.BAD_PAYLOAD)

            if command not in names_map:
                raise AdHocError(Error.BAD_PAYLOAD, text=f"Invalid command: {command!r}.")

            path, iface, command = names_map[command]
            assert self.session_con is not None
            proxy = await self.session_con.getRemoteObject(bus_name, path)

            await self._dbus_async_call(proxy, command, interface=iface)

            # Job done, we can end the session, except if we have FLAG_LOOP.
            if FLAG_LOOP in flags:
                return AdHocCallbackData(
                    form=session_data["command_form"],
                    status=Status.EXECUTING,
                    notes=[Note(text=D_("Command sent."))],
                )
            else:
                return AdHocCallbackData(
                    status=Status.COMPLETED, notes=[Note(text=D_("Command sent."))]
                )

    ## Local media ##

    def _ad_hoc_remotes_get(self, profile):
        return self.ad_hoc_remotes_get(self.host.get_client(profile))

    async def ad_hoc_remotes_get(
        self, client: SatXMPPEntity
    ) -> list[tuple[str, str, str]]:
        """Retrieve available remote media controlers in our devices.

        @return: list of devices with:
            - entity full jid
            - device name
            - device label
        """
        found_data = await defer.ensureDeferred(
            self.host.find_by_features(
                client,
                [self.host.ns_map["commands"]],
                service=False,
                roster=False,
                own_jid=True,
                local_device=True,
            )
        )

        remotes = []

        for found in found_data:
            for device_jid_s in found:
                device_jid = jid.JID(device_jid_s)
                cmd_list = await self._c.list_commands(client, device_jid)
                for cmd in cmd_list:
                    if cmd.nodeIdentifier == NS_MEDIA_PLAYER:
                        try:
                            result_elt = await self._c.do(
                                client, device_jid, NS_MEDIA_PLAYER, timeout=5
                            )
                            command_elt = self._c.get_command_elt(result_elt)
                            form = data_form.findForm(command_elt, NS_MEDIA_PLAYER)
                            if form is None:
                                continue
                            mp_options = form.fields["media_player"].options
                            session_id = command_elt.getAttribute("sessionid")
                            if mp_options and session_id:
                                # we just want to discover player, so we cancel the
                                # session
                                self._c.do(
                                    client,
                                    device_jid,
                                    NS_MEDIA_PLAYER,
                                    action=Action.CANCEL,
                                    session_id=session_id,
                                )

                            for opt in mp_options:
                                remotes.append(
                                    (device_jid_s, opt.value, opt.label or opt.value)
                                )
                        except Exception as e:
                            log.warning(
                                _(
                                    "Can't retrieve remote controllers on {device_jid}: "
                                    "{reason}".format(device_jid=device_jid, reason=e)
                                )
                            )
                        break
        return remotes

    async def do_mpris_command(self, proxy, command):
        iface, command = command.rsplit(".", 1)
        if command == CMD_GO_BACK:
            command = "Seek"
            args = [-SEEK_OFFSET]
        elif command == CMD_GO_FWD:
            command = "Seek"
            args = [SEEK_OFFSET]
        else:
            args = []
        return await self._dbus_async_call(proxy, command, *args, interface=iface)

    def add_mpris_metadata(self, form: DataForm, metadata: dict):
        """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP"""
        for mpris_key, name in MPRIS_METADATA_MAP.items():
            if mpris_key in metadata:
                value = str(metadata[mpris_key])
                form.fields.append(Text(type="fixed", var=name, value=value))

    async def local_media_cb(
        self,
        client: SatXMPPEntity,
        page_data: PageData,
    ) -> AdHocCallbackData:
        """Ad-hoc command for controlling media players via D-Bus.

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

        @raise AdHocError: Something went wrong.
        """
        assert txdbus is not None

        if page_data.idx == 0:
            # root request, we looks for media players
            bus_names = await self._dbus_list_names()
            bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
            if len(bus_names) == 0:
                return AdHocCallbackData(
                    status=Status.COMPLETED,
                    notes=[Note(text=D_("No media player found."))],
                )
            options = []
            form = DataForm(
                title=D_("Media Player Selection"),
                namespace=NS_MEDIA_PLAYER,
            )
            for bus in bus_names:
                player_name = bus[len(MPRIS_PREFIX) + 1 :]
                if not player_name:
                    log.warning(_("Ignoring MPRIS bus without suffix."))
                    continue
                options.append(Option(value=bus, label=player_name))
            form.fields.append(
                ListSingle(var="media_player", options=options, required=True)
            )
            return AdHocCallbackData(form=form, status=Status.EXECUTING)
        else:
            # player request
            try:
                bus_name = page_data.session_data["bus_name"]
            except KeyError:
                try:
                    bus_name = page_data.form.get_field("media_player", ListSingle).value
                    if not bus_name:
                        raise KeyError
                except KeyError:
                    raise AdHocError(
                        Error.BAD_PAYLOAD, text=D_("missing media_player value")
                    )
                else:
                    page_data.session_data["bus_name"] = bus_name

            if not bus_name.startswith(MPRIS_PREFIX):
                log.warning(
                    "Media player ad-hoc command trying to use non MPRIS bus. Hack "
                    f"attempt? Refused bus: {bus_name}"
                )
                raise AdHocError(Error.BAD_PAYLOAD, text=D_("Invalid player name."))
            assert self.session_con is not None
            try:
                proxy = await self.session_con.getRemoteObject(
                    bus_name, MPRIS_PATH, "org.mpris.MediaPlayer2.Player"
                )
            except Exception as e:
                log.exception(f"Can't get D-Bus proxy.")
                raise AdHocError(
                    Error.ITEM_NOT_FOUND, D_("Media player is not available anymore")
                )
            try:
                command = page_data.form.get_field("command", ListSingle).value
                if not command:
                    raise KeyError
            except KeyError:
                pass
            else:
                await self.do_mpris_command(proxy, command)

            # we construct the remote control form
            form = DataForm(
                title=D_("Media Player Selection"),
            )
            for iface, properties_names in MPRIS_PROPERTIES.items():
                for name in properties_names:
                    try:
                        value = await self._dbus_get_property(proxy, iface, name)
                    except Exception as e:
                        log.warning(f"Can't retrieve attribute {name}: {e}")
                        continue
                    if name == MPRIS_METADATA_KEY:
                        self.add_mpris_metadata(form, value)
                    else:
                        form.fields.append(Text(type="fixed", var=name, value=str(value)))

            commands = [
                Option(value=c, label=c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS
            ]
            form.fields.append(ListSingle(var="command", options=commands, required=True))

            return AdHocCallbackData(form=form, status=Status.EXECUTING)
