#!/bin/python

###############################################################################
# An example that creates a NMClient instance for another GMainContext
# and iterates the context while doing an async D-Bus call.
#
# D-Bus is fundamentally async. libnm's NMClient API caches D-Bus objects
# on NetworkManager's D-Bus API. As such, it is "frozen" (with the current
# content of the cache) while not iterating the GMainContext. Only by iterating
# the GMainContext any events are processed and things change.
#
# This means, NMClient heavily uses GMainContext (and GDBusConnection)
# and to operate it, you need to iterate the GMainContext. The synchronous
# API (like NM.Client.new()) is for simple programs but usually not best
# for using NMClient for real applications.
#
# To learn more about GMainContext, read https://developer.gnome.org/documentation/tutorials/main-contexts.html
# When I say "mainloop" or "event loop", I mean GMainContext. GMainLoop is
# a small wrapper around GMainContext to run the context with a boolean
# flag.
#
# Usually, non trivial applications run the GMainContext (or GMainLoop)
# from the main() function and aside some setup and teardown, everything
# happens as events from the event loop.
# This example instead performs synchronous steps, and at the places where
# we need to get the result of some async operation, we iterate the GMainContext
# until we get the result. This may not be how a complex application works,
# but you might do this on a simpler application (like a script) that iterates
# the mainloop whenever it needs to wait for async operations to complete.
#
# Iterating the mainloop might dispatch any other sources that are ready.
# In this example nobody else is scheduling unrelated timers or events, but
# if that happens, your application needs to cope with that.
# E.g. while iterating the mainloop many times, still don't nest running the
# same main context (unless you really know what you do).

###############################################################################

import sys
import time
import traceback

import gi

gi.require_version("NM", "1.0")
from gi.repository import NM, GLib, Gio


###############################################################################


def log(msg=None, prefix=None, suffix="\n"):
    # We use nm_utils_print(), because that uses the same logging
    # mechanism as if you run with "LIBNM_CLIENT_DEBUG=trace". This
    # ensures that messages are in sync.
    if msg is None:
        NM.utils_print(0, "\n")
        return
    if prefix is None:
        prefix = f"[{time.monotonic():.5f}] "
    NM.utils_print(0, f"{prefix}{msg}{suffix}")


def error_is_cancelled(e):
    # Whether error is due to cancellation.
    if isinstance(e, GLib.GError):
        if e.domain == "g-io-error-quark" and e.code == Gio.IOErrorEnum.CANCELLED:
            return True
    return False


###############################################################################


# A Context manager for running a mainloop. Of course, this does
# not do anything magically. You can run the context/mainloop without
# this context object.
#
# This is just to show how we could iterate the GMainContext while waiting
# for an async reply. Note that many non-trivial applications that use glib
# would instead run the mainloop from the main function, only running it once,
# but for the entire duration of the program.
#
# This example and MainLoopRun instead assume that you iterate the maincontext
# for short durations at a time. In particular in this case, where there is
# a dedicated maincontext only for NMClient.
class MainLoopRun:
    def __init__(self, info, ctx, timeout=None):
        self._info = info
        self._loop = GLib.MainLoop(ctx)
        self.cancellable = Gio.Cancellable()
        self._timeout = timeout
        self.got_timeout = False
        self.result = None
        self.error = None
        log(f"MainLoopRun[{self._info}]: create with timeout {self._timeout}")

    def _timeout_cb(self, _):
        log(f"MainLoopRun[{self._info}]: timeout")
        self.got_timeout = True
        self._detach()
        self.cancellable.cancel()
        return False

    def _cancellable_cb(self):
        log(f"MainLoopRun[{self._info}]: cancelled")

    def _detach(self):
        if self._timeout_source is not None:
            self._timeout_source.destroy()
            self._timeout_source = None
        if self._cancellable_id is not None:
            self.cancellable.disconnect(self._cancellable_id)
            self._cancellable_id = None

    def __enter__(self):
        log(f"MainLoopRun[{self._info}]: enter")
        self._timeout_source = None
        if self._timeout is not None:
            self._timeout_source = GLib.timeout_source_new(int(self._timeout * 1000))
            self._timeout_source.set_callback(self._timeout_cb)
            self._timeout_source.attach(self._loop.get_context())
        self._cancellable_id = self.cancellable.connect(self._cancellable_cb)
        self._loop.get_context().push_thread_default()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            # Exception happened.
            log(f"MainLoopRun[{self._info}]: exit with exception")
        else:
            log(f"MainLoopRun[{self._info}]: exit: start mainloop")

            self._loop.run()

            if self.error is not None:
                log(
                    f"MainLoopRun[{self._info}]: exit: complete with error {self.error}"
                )
            elif self.result is not None:
                log(
                    f"MainLoopRun[{self._info}]: exit: complete with result {self.result}"
                )
            else:
                log(f"MainLoopRun[{self._info}]: exit: complete with success")

        self._detach()
        self._loop.get_context().pop_thread_default()
        return False

    def quit(self):
        log(f"MainLoopRun[{self._info}]: quit mainloop")
        self._detach()
        self._loop.quit()


###############################################################################


def get_bus():
    # Let's get the GDBusConnection singleton by calling Gio.bus_get().
    # Since we do everything async, use Gio.bus_get() instead Gio.bus_get_sync().
    with MainLoopRun("get_bus", None, 1) as r:

        def bus_get_cb(source, result, r):
            try:
                c = Gio.bus_get_finish(result)
            except Exception as e:
                r.error = e
            else:
                r.result = c
            r.quit()

        Gio.bus_get(Gio.BusType.SYSTEM, r.cancellable, bus_get_cb, r)

    return r.result


###############################################################################


def create_nmc(dbus_connection):
    # Show how to create and initialize a NMClient asynchronously.
    #
    # NMClient implements GAsyncInitableIface, it thus can be initialized
    # asynchronously. That has actually an advantage, because the sync
    # initialization (GInitableIface) requires to create an internal GMainContext
    # which has an overhead.
    #
    # Also, split the GObject creation and the init_async() call in two.
    # That allows to pass construct-only parameters, in particular like
    # the instance_flags.

    # Create a separate context for the NMClient. The NMClient is strongly
    # tied to the context used at construct time.
    ctx = GLib.MainContext()
    ctx.push_thread_default()

    log(f"[create_nmc]: use separate context for NMClient: ctx={ctx}")
    try:
        # We create a client asynchronously. There is synchronous
        # NM.Client(), however that requires an internal GMainContext
        # and has thus an overhead. Also, it's obviously blocking.
        #
        # Instead, we initialize it asynchronously, which means
        # we need to iterate the main context. In this case, the
        # context cannot have any other sources dispatched, but
        # if there would be other sources, they might be dispatched
        # while iterating (so this is waiting for the result, but
        # may also dispatch unrelated sources (if any), which you would need
        # to handle).
        #
        # Also, only when using the GObject constructor directly, we can
        # suppress loading the permissions and pass a D-Bus connection.
        nmc = NM.Client(
            instance_flags=NM.ClientInstanceFlags.NO_AUTO_FETCH_PERMISSIONS,
            dbus_connection=dbus_connection,
        )
        log(f"[create_nmc]: new NMClient instance: {nmc}")
    finally:
        # We actually don't need that the ctx is the current thread default
        # later on. NMClient will automatically push it, when necessary.
        ctx.pop_thread_default()

    with MainLoopRun("create_mnc", nmc.get_main_context(), 2) as r:

        def _async_init_cb(nmc, result, r):
            try:
                nmc.init_finish(result)
            except Exception as e:
                log(f"[create_nmc]: init_async() completed with error: {e}")
                r.error = e
            else:
                log(f"[create_nmc]: init_async() completed with success")
            r.quit()

        log(f"[create_nmc]: start init_async()")
        nmc.init_async(GLib.PRIORITY_DEFAULT, r.cancellable, _async_init_cb, r)

    if r.error is None:
        if nmc.get_nm_running():
            log(
                f"[create_nmc]: completed with success (daemon version: {nmc.get_version()}, D-Bus daemon unique name: {nmc.get_dbus_name_owner()})"
            )
        else:
            log(f"[create_nmc]: completed with success (daemon not running)")
        return nmc
    if error_is_cancelled(r.error):
        # Cancelled by us. This happened because we hit the timeout with
        # MainLoopRun.
        log(f"[create_nmc]: failed to initialize within timeout")
        return None
    if not nmc.get_dbus_connection():
        # The NMClient has no D-Bus connection, it usually would try
        # to get one via Gio.bus_get(), but it failed.
        log(f"[create_nmc]: failed to create D-Bus connection: {r.error}")
        return None

    log(f"[create_nmc]: unexpected error creating NMClient ({r.error})")
    # This actually should not happen. There is no other reason why
    # initialization can fail.
    assert False, "NMClient initialization is not supposed to fail"


###############################################################################


def make_call(nmc):
    log("[make_call]: make some async D-Bus call")

    if not nmc:
        log("[make_call]: no NMClient. Skip")
        return

    with MainLoopRun("make_call", nmc.get_main_context(), 1) as r:
        # There are two reasons why async operations are preferable with
        # D-Bus and libnm:
        #
        # - pseudo blocking messes with the ordering of events (see https://smcv.pseudorandom.co.uk/2008/11/nonblocking/).
        # - blocking prevents other things from happening and combining synchronous calls is more limited.
        #
        # So doing async operations is mostly interesting when performing multiple operations in
        # parallel, or when we still want to handle other events while waiting for the reply.
        # The example here does not cover that usage well, because there is only one thing happening.

        def _dbus_call_cb(nmc, result, r):
            try:
                res = nmc.dbus_call_finish(result)
            except Exception as e:
                if error_is_cancelled(e):
                    log(
                        f"[make_call]: dbus_call() completed with cancellation after timeout"
                    )
                else:
                    log(f"[make_call]: dbus_call() completed with error: {e}")

                # I don't understand why, but if you hit this exception (e.g. by setting a low
                # timeout) and pass the exception to the out context, then an additional reference
                # to nmc is leaked, and destroy_nmc() will fail. Workaround
                #
                # r.error = e
                r.error = str(e)
            else:
                log(
                    f"[make_call]: dbus_call() completed with success: {str(res)[:40]}..."
                )
            r.quit()

        log(f"[make_call]: start GetPermissions call")
        nmc.dbus_call(
            NM.DBUS_PATH,
            NM.DBUS_INTERFACE,
            "GetPermissions",
            GLib.Variant.new_tuple(),
            GLib.VariantType("(a{ss})"),
            1000,
            r.cancellable,
            _dbus_call_cb,
            r,
        )

    return r.error is None


###############################################################################


def destroy_nmc(nmc_holder, destroy_mode):
    # The way to shutdown an NMClient is just by unrefing it.
    #
    # At any moment, can an NMClient instance have pending async operations.
    # While unrefing NMClient will cancel them right away, they are only
    # reaped when we iterate the GMainContext some more. That means, if we don't
    # want to leak the GMainContext and the pending operations, we must
    # iterate it some more.
    #
    # To know how much more, there is nmc.get_context_busy_watcher(),
    # We can subscribe a weak reference and keep iterating as long
    # as the watcher is alive.
    #
    # Of course, this only applies if the application wishes to keep running
    # but no longer iterating NMClient's GMainContext. Then you need to ensure
    # that all pending operations in GMainContext are completed (by iterating it).
    #
    # In python, that is a bit tricky, because the caller of destroy_nmc()
    # must give up its reference and pass it here via the @nmc_holder list.
    # You must call destroy_nmc() without having any other reference on
    # nmc.
    #
    # This is just an example. This relies that on this point we only have
    # one reference to NMClient (and it's held by the nmc_holder list).
    # Usually you wouldn't make assumptions about this. Instead, you just
    # assume that you need to keep iterating the GMainContext as long as
    # the context busy watcher is alive, regardless that at this point others
    # might still hold references on the NMClient.

    # Transfer the nmc reference out of the list.
    (nmc,) = nmc_holder
    nmc_holder.clear()

    log(
        f"[destroy_nmc]: destroying NMClient {nmc}: pyref={sys.getrefcount(nmc)}, ref_count={nmc.ref_count}, destroy_mode={destroy_mode}"
    )

    if destroy_mode == 0:
        ctx = nmc.get_main_context()

        finished = []

        def _weak_ref_cb():
            log(f"[destroy_nmc]: context busy watcher is gone")
            finished.clear()
            finished.append(True)

        # We take a weak ref on the context-busy-watcher object and give up
        # our reference on nmc. This must be the last reference, which initiates
        # the shutdown of the NMClient.
        weak_ref = nmc.get_context_busy_watcher().weak_ref(_weak_ref_cb)
        del nmc

        def _timeout_cb(unused):
            if not finished:
                # Somebody else holds a reference to the NMClient and keeps
                # it alive. We cannot properly clean up.
                log(
                    f"[destroy_nmc]: ERROR: timeout waiting for context busy watcher to be gone"
                )
                finished.append(False)
            return False

        timeout_source = GLib.timeout_source_new(1000)
        timeout_source.set_callback(_timeout_cb)
        timeout_source.attach(ctx)

        while not finished:
            log(f"[destroy_nmc]: iterating main context")
            ctx.iteration(True)

        timeout_source.destroy()

        log(f"[destroy_nmc]: done: {finished[0]}")
        if not finished[0]:
            weak_ref.unref()
            raise Exception("Failure to destroy NMClient: something keeps it alive")

    else:
        if destroy_mode == 1:
            ctx = GLib.MainContext.default()
        else:
            # Run the maincontext of the NMClient.
            ctx = nmc.get_main_context()
        with MainLoopRun("destroy_nmc", ctx, 2) as r:

            def _wait_shutdown_cb(source_unused, result, r):
                try:
                    NM.Client.wait_shutdown_finish(result)
                except Exception as e:
                    if error_is_cancelled(e):
                        log(
                            f"[destroy_nmc]: wait_shutdown() completed with cancellation after timeout"
                        )
                    else:
                        log(f"[destroy_nmc]: wait_shutdown() completed with error: {e}")
                else:
                    log(f"[destroy_nmc]: wait_shutdown() completed with success")

                r.quit()

            nmc.wait_shutdown(True, r.cancellable, _wait_shutdown_cb, r)
            del nmc


###############################################################################


def run1():
    try:
        dbus_connection = get_bus()
        log()

        nmc = create_nmc(dbus_connection)
        log()

        make_call(nmc)
        log()

        if not nmc:
            log(f"[destroy_nmc]: nothing to destroy")
        else:
            # To cleanup the NMClient, we need to give up the reference. Move
            # it to a list, and destroy_nmc() will take care of it.
            nmc_holder = [nmc]
            del nmc

            # In the example, there are three modes how the destroy is
            # implemented.
            destroy_nmc(nmc_holder, destroy_mode=1)

        log()
        log("done")
    except Exception as e:
        log()
        log("EXCEPTION:")
        log(f"{e}")
        for tb in traceback.format_exception(None, e, e.__traceback__):
            for l in tb.split("\n"):
                log(f">>> {l}")
        return False
    return True


if __name__ == "__main__":
    if not run1():
        sys.exit(1)
