// SPDX-License-Identifier: LGPL-2.1-or-later
import cockpit from "cockpit";

/* SERVICE MANAGEMENT API
 *
 * The "service" module lets you monitor and manage a
 * system service on localhost in a simple way.
 *
 * It mainly exists because talking to the systemd D-Bus API is
 * not trivial enough to do it directly.
 *
 * - proxy = service.proxy(name)
 *
 * Create a proxy that represents the service named NAME.
 *
 * The proxy has properties and methods (described below) that
 * allow you to monitor the state of the service, and perform
 * simple actions on it.
 *
 * Initially, any of the properties can be "null" until their
 * actual values have been retrieved in the background.
 *
 * - proxy.addEventListener('changed', event => { ... })
 *
 * The 'changed' event is emitted whenever one of the properties
 * of the proxy changes.
 *
 * - proxy.exists
 *
 * A boolean that tells whether the service is known or not.  A
 * proxy with 'exists == false' will have 'state == undefined' and
 * 'enabled == undefined'.
 *
 * - proxy.state
 *
 * Either 'undefined' when the state can't be retrieved, or a
 * string that has one of the values "starting", "running",
 * "stopping", "stopped", or "failed".
 *
 * - proxy.enabled
 *
 * Either 'undefined' when the value can't be retrieved, or a
 * boolean that tells whether the service is started 'enabled'.
 * What it means exactly for a service to be enabled depends on
 * the service, but an enabled service is usually started on boot,
 * no matter whether other services need it or not.  A disabled
 * service is usually only started when it is needed by some other
 * service.
 *
 * - proxy.unit
 * - proxy.details
 *
 * The raw org.freedesktop.systemd1.Unit and type-specific D-Bus
 * interface proxies for the service.
 *
 * - proxy.service
 *
 * The deprecated name for proxy.details
 *
 * - promise = proxy.start()
 *
 * Start the service.  The return value is a standard jQuery
 * promise as returned from DBusClient.call.
 *
 * - promise =  proxy.restart()
 *
 * Restart the service.
 *
 * - promise = proxy.tryRestart()
 *
 * Try to restart the service if it's running or starting
 *
 * - promise = proxy.stop()
 *
 * Stop the service.
 *
 * - promise = proxy.enable()
 *
 * Enable the service.
 *
 * - promise = proxy.disable()
 *
 * Disable the service.
 *
 * - journal = proxy.getRunJournal(options)
 *
 * Return the journal of the current (if running) or recent (if failed/stopped) service run,
 * similar to `systemctl status`. `options` is an optional array that gets appended to the `journalctl` call.
 */

let systemd_client;
let systemd_manager;

function wait_valid(proxy, callback) {
    proxy.wait(() => {
        if (proxy.valid)
            callback();
    });
}

function with_systemd_manager(done) {
    if (!systemd_manager) {
        // cached forever, only used for reading/watching; no superuser
        systemd_client = cockpit.dbus("org.freedesktop.systemd1");
        systemd_manager = systemd_client.proxy("org.freedesktop.systemd1.Manager",
                                               "/org/freedesktop/systemd1");
        wait_valid(systemd_manager, () => {
            systemd_manager.Subscribe()
                    .catch(error => {
                        if (error.name != "org.freedesktop.systemd1.AlreadySubscribed" &&
                        error.name != "org.freedesktop.DBus.Error.FileExists")
                            console.warn("Subscribing to systemd signals failed", error);
                    });
        });
    }
    wait_valid(systemd_manager, done);
}

export function proxy(name, kind) {
    const self = {
        exists: null,
        state: null,
        enabled: null,

        wait,

        start,
        stop,
        restart,
        tryRestart,

        enable,
        disable,

        getRunJournal,
    };

    cockpit.event_target(self);

    let unit;
    let details;
    let wait_promise_resolve;
    const wait_promise = new Promise(resolve => { wait_promise_resolve = resolve });

    if (name.indexOf(".") == -1)
        name = name + ".service";
    if (kind === undefined)
        kind = "Service";

    function update_from_unit() {
        self.exists = (unit.LoadState != "not-found" || unit.ActiveState != "inactive");

        if (unit.ActiveState == "activating")
            self.state = "starting";
        else if (unit.ActiveState == "deactivating")
            self.state = "stopping";
        else if (unit.ActiveState == "active" || unit.ActiveState == "reloading")
            self.state = "running";
        else if (unit.ActiveState == "failed")
            self.state = "failed";
        else if (unit.ActiveState == "inactive" && self.exists)
            self.state = "stopped";
        else
            self.state = undefined;

        if (unit.UnitFileState == "enabled" || unit.UnitFileState == "linked")
            self.enabled = true;
        else if (unit.UnitFileState == "disabled" || unit.UnitFileState == "masked")
            self.enabled = false;
        else
            self.enabled = undefined;

        self.unit = unit;

        self.dispatchEvent("changed");
        wait_promise_resolve();
    }

    function update_from_details() {
        self.details = details;
        self.service = details;
        self.dispatchEvent("changed");
    }

    with_systemd_manager(function () {
        systemd_manager.LoadUnit(name)
                .then(path => {
                    unit = systemd_client.proxy('org.freedesktop.systemd1.Unit', path);
                    unit.addEventListener('changed', update_from_unit);
                    wait_valid(unit, update_from_unit);

                    details = systemd_client.proxy('org.freedesktop.systemd1.' + kind, path);
                    details.addEventListener('changed', update_from_details);
                    wait_valid(details, update_from_details);
                })
                .catch(() => {
                    self.exists = false;
                    self.dispatchEvent('changed');
                });
    });

    function refresh() {
        if (!unit || !details)
            return Promise.resolve();

        function refresh_interface(path, iface) {
            return systemd_client.call(path, "org.freedesktop.DBus.Properties", "GetAll", [iface])
                    .then(([result]) => {
                        const props = { };
                        for (const p in result)
                            props[p] = result[p].v;
                        systemd_client.notify({ [unit.path]: { [iface]: props } });
                    })
                    .catch(error => console.log(error));
        }

        return Promise.allSettled([
            refresh_interface(unit.path, "org.freedesktop.systemd1.Unit"),
            refresh_interface(details.path, "org.freedesktop.systemd1." + kind),
        ]);
    }

    function on_job_new_removed_refresh(event, number, path, unit_id, result) {
        if (unit_id == name)
            refresh();
    }

    /* HACK - https://github.com/systemd/systemd/issues/570#issuecomment-125334529
     *
     * We need to explicitly get new property values when getting
     * a UnitNew signal since UnitNew doesn't carry them.
     * However, reacting to UnitNew with GetAll could lead to an
     * infinite loop since systemd emits a UnitNew in reaction to
     * GetAll for units that it doesn't want to keep loaded, such
     * as units without unit files.
     *
     * So we ignore UnitNew and instead assume that the unit state
     * only changes in interesting ways when there is a job for it
     * or when the daemon is reloaded (or when we get a property
     * change notification, of course).
     */

    // This is what we want to do:
    // systemd_manager.addEventListener("UnitNew", function (event, unit_id, path) {
    //     if (unit_id == name)
    //         refresh();
    // });

    // This is what we have to do:
    systemd_manager.addEventListener("Reloading", (event, reloading) => {
        if (!reloading)
            refresh();
    });

    systemd_manager.addEventListener("JobNew", on_job_new_removed_refresh);
    systemd_manager.addEventListener("JobRemoved", on_job_new_removed_refresh);

    function wait(callback) {
        wait_promise.then(callback);
    }

    /* Actions
     *
     * We don't call methods on the persistent systemd_client, as that does not have superuser
     */

    function call_manager(dbus, method, args) {
        return dbus.call("/org/freedesktop/systemd1",
                         "org.freedesktop.systemd1.Manager",
                         method, args);
    }

    function call_manager_with_job(method, args) {
        return new Promise((resolve, reject) => {
            const dbus = cockpit.dbus("org.freedesktop.systemd1", { superuser: "try" });
            let pending_job_path;

            const subscription = dbus.subscribe(
                { interface: "org.freedesktop.systemd1.Manager", member: "JobRemoved" },
                (_path, _iface, _signal, [_number, path, _unit_id, result]) => {
                    if (path == pending_job_path) {
                        subscription.remove();
                        dbus.close();
                        refresh().then(() => {
                            if (result === "done")
                                resolve();
                            else
                                reject(new Error(`systemd job ${method} ${JSON.stringify(args)} failed with result ${result}`));
                        });
                    }
                });

            call_manager(dbus, method, args)
                    .then(([path]) => { pending_job_path = path })
                    .catch(ex => {
                        dbus.close();
                        reject(ex);
                    });
        });
    }

    function call_manager_with_reload(method, args) {
        const dbus = cockpit.dbus("org.freedesktop.systemd1", { superuser: "try" });
        return call_manager(dbus, method, args)
                .then(() => call_manager(dbus, "Reload", []))
                .then(refresh)
                .finally(dbus.close);
    }

    function start() {
        return call_manager_with_job("StartUnit", [name, "replace"]);
    }

    function stop() {
        return call_manager_with_job("StopUnit", [name, "replace"]);
    }

    function restart() {
        return call_manager_with_job("RestartUnit", [name, "replace"]);
    }

    function tryRestart() {
        return call_manager_with_job("TryRestartUnit", [name, "replace"]);
    }

    function enable() {
        return call_manager_with_reload("EnableUnitFiles", [[name], false, false]);
    }

    function disable() {
        return call_manager_with_reload("DisableUnitFiles", [[name], false]);
    }

    function getRunJournal(options) {
        if (!details || !details.ExecMainStartTimestamp)
            return Promise.reject(new Error("getRunJournal(): unit is not known"));

        // collect the service journal since start time; property is μs, journal wants s
        const startTime = Math.floor(details.ExecMainStartTimestamp / 1000000);
        return cockpit.spawn(
            ["journalctl", "--unit", name, "--since=@" + startTime.toString()].concat(options || []),
            { superuser: "try", error: "message" });
    }

    return self;
}
