/*
 * Copyright (C) 2020 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

/* Manage a long-running precious process that runs independently from a Cockpit
 * session in a transient systemd service unit. See
 * examples/long-running-process/README.md for details.
 *
 * The unit will run as root, on the system systemd manager, so that every privileged
 * Cockpit session shares the same unit. The same approach works in principle on
 * the user's systemd instance, but the current code  does not support that as it
 * is not a common use case for Cockpit.
 */

/* global cockpit */

// systemd D-Bus API names
const O_SD_OBJ = "/org/freedesktop/systemd1";
const I_SD_MGR = "org.freedesktop.systemd1.Manager";
const I_SD_UNIT = "org.freedesktop.systemd1.Unit";
const I_DBUS_PROP = "org.freedesktop.DBus.Properties";

/* Possible LongRunningProcess.state values */
export const ProcessState = {
    INIT: 'init',
    STOPPED: 'stopped',
    RUNNING: 'running',
    FAILED: 'failed',
};

export class LongRunningProcess {
    /* serviceName: systemd unit name to start or reattach to
     * updateCallback: function that gets called whenever the state changed; first and only
     *                 argument is `this` LongRunningProcess instance.
     */
    constructor(serviceName, updateCallback) {
        this.systemdClient = cockpit.dbus("org.freedesktop.systemd1", { superuser: "require" });
        this.serviceName = serviceName;
        this.updateCallback = updateCallback;
        this._setState(ProcessState.INIT);
        this.startTimestamp = null; // µs since epoch
        this.terminated = false;

        // Watch for start event of the service
        this.systemdClient.subscribe({ interface: I_SD_MGR, member: "JobNew" }, (path, iface, signal, args) => {
            if (args[2] == this.serviceName)
                this._checkState();
        });

        // Check if it is already running
        this._checkState();
    }

    /** Start long-running process. Only call this in states STOPPED or FAILED.
     *  This runs as root, thus will be shared with all privileged Cockpit sessions.
     *  Return cockpit.spawn promise. You need to handle exceptions, but not success.
     * @param {string[]} argv array starting with executable and all of the arguments to pass to it
     * @param {object} options same properties as cockpit-spawn https://cockpit-project.org/guide/latest/cockpit-spawn.html
     * @param {string[]} runArgs arguments to provide to systemd-run itself, such as `--setenv`
     */
    run(argv, options, runArgs = []) {
        if (this.state !== ProcessState.STOPPED && this.state !== ProcessState.FAILED)
            throw new Error(`cannot start LongRunningProcess in state ${this.state}`);
        // no need to directly react to this -- JobNew and _checkState() will pick up when the unit runs
        return cockpit.spawn(
            [
                "systemd-run",
                "--unit", this.serviceName,
                "--service-type=oneshot",
                "--no-block",
                ...runArgs,
                "--"
            ].concat(argv),
            { superuser: "require", err: "message", ...options }
        );
    }

    /*  Stop long-running process while it is RUNNING, or reset a FAILED one */
    terminate() {
        if (this.state !== ProcessState.RUNNING && this.state !== ProcessState.FAILED)
            throw new Error(`cannot terminate LongRunningProcess in state ${this.state}`);

        /* This sends a SIGTERM to the unit, causing it to go into "failed" state. This would not
         * happen with `systemd-run -p SuccessExitStatus=0`, but that does not yet work on older
         * OSes with systemd ≤ 241 So let checkState() know that a failure is due to termination. */
        this.terminated = true;
        return this.systemdClient.call(O_SD_OBJ, I_SD_MGR, "StopUnit", [this.serviceName, "replace"], { type: "ss" });
    }

    reset() {
        if (this.state === ProcessState.FAILED)
            this.systemdClient.call(O_SD_OBJ, I_SD_MGR, "ResetFailedUnit", [this.serviceName], { type: "s" });
        else
            throw new Error(`cannot reset LongRuningProcess in state ${this.state}`);
    }
    /*
     * below are internal private methods
     */

    _setState(state) {
        /* PropertiesChanged often gets fired multiple times with the same values, avoid UI flicker */
        if (state === this.state)
            return;
        this.state = state;
        this.terminated = false;
        if (this.updateCallback)
            this.updateCallback(this);
    }

    _setStateFromProperties(activeState, stateChangeTimestamp) {
        switch (activeState) {
        case 'activating':
            this.startTimestamp = stateChangeTimestamp;
            this._setState(ProcessState.RUNNING);
            break;
        case 'failed':
            this.startTimestamp = null; // TODO: can we derive this from InvocationID?
            if (this.terminated) {
                /* terminating causes failure; reset that and do not announce it as failed */
                this.systemdClient.call(O_SD_OBJ, I_SD_MGR, "ResetFailedUnit", [this.serviceName], { type: "s" });
            } else {
                this._setState(ProcessState.FAILED);
            }
            break;
        case 'inactive':
            this._setState(ProcessState.STOPPED);
            break;
        case 'deactivating':
            /* ignore these transitions */
            break;
        default:
            throw new Error(`unexpected state of unit ${this.serviceName}: ${activeState}`);
        }
    }

    // check if the transient unit for our command is running
    _checkState() {
        this.systemdClient.call(O_SD_OBJ, I_SD_MGR, "GetUnit", [this.serviceName], { type: "s" })
                .then(([unitObj]) => {
                    /* Some time may pass between getting JobNew and the unit actually getting activated;
                     * we may get an inactive unit here; watch for state changes. This will also update
                     * the UI if the unit stops. */
                    this.subscription = this.systemdClient.subscribe(
                        { interface: I_DBUS_PROP, member: "PropertiesChanged" },
                        (path, iface, signal, args) => {
                            if (path === unitObj && args[1].ActiveState && args[1].StateChangeTimestamp)
                                this._setStateFromProperties(args[1].ActiveState.v, args[1].StateChangeTimestamp.v);
                        });

                    this.systemdClient.call(unitObj, I_DBUS_PROP, "GetAll", [I_SD_UNIT], { type: "s" })
                            .then(([props]) => this._setStateFromProperties(props.ActiveState.v, props.StateChangeTimestamp.v))
                            .catch(ex => {
                                throw new Error(`unexpected failure of GetAll(${unitObj}): ${ex.toString()}`);
                            });
                })
                .catch(ex => {
                    if (ex.name === "org.freedesktop.systemd1.NoSuchUnit") {
                        if (this.subscription) {
                            this.subscription.remove();
                            this.subscription = null;
                        }
                        this._setState(ProcessState.STOPPED);
                    } else {
                        throw new Error(`unexpected failure of GetUnit(${this.serviceName}): ${ex.toString()}`);
                    }
                });
    }
}
