/**  
 * Driver for handling thresholds in Lenovo ThinkPad series for models since 2011
 * 
 * Based on information from https://linrunner.de/tlp/faq/battery.html
 * 
 * Original sources: https://github.com/linrunner/TLP/blob/main/bat.d/05-thinkpad
 */
'use strict';

import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';

/* 
 * On some models the start threshold 0 is not allowed, instead 95 is used (Ex: E14 Gen 3) 
 * See issues:
 *      #8: https://gitlab.com/marcosdalvarez/thinkpad-battery-threshold-extension/-/issues/8
 *      #20: https://gitlab.com/marcosdalvarez/thinkpad-battery-threshold-extension/-/issues/20
 */
const ALTERNATIVE_DISABLED_START = /(Think[pP]ad) (E14 Gen 3|P14s Gen 2a)/;

// Driver constants
const BASE_PATH = '/sys/class/power_supply';
const START_FILE_OLD = 'charge_start_threshold'; // kernel 4.17 and newer
const END_FILE_OLD = 'charge_stop_threshold'; // kernel 4.17 and newer
const START_FILE_NEW = 'charge_control_start_threshold'; // kernel 5.9 and newer
const END_FILE_NEW = 'charge_control_end_threshold'; // kernel 5.9 and newer

/**
 * Read file contents
 * 
 * @param {string} path Path of file
 * @returns {string} File contents
 */
const readFile = function (path) {
    try {
        const f = Gio.File.new_for_path(path);
        const [, contents,] = f.load_contents(null);
        const decoder = new TextDecoder('utf-8');
        return decoder.decode(contents);
    } catch (e) {
        return null;
    }
}

/**
 * Read integer value from file
 * 
 * @param {string} path Path of file
 * @returns {number|null} Return a integer or null
 */
const readFileInt = function (path) {
    try {
        const v = readFile(path);
        if (v) {
            return parseInt(v);
        } else {
            return null;
        }
    } catch (e) {
        return null;
    }
}

/**
 * Test file/directory exists
 * 
 * @param {string} path File/directory path
 * @returns {boolean}
 */
const fileExists = function (path) {
    try {
        const f = Gio.File.new_for_path(path);
        return f.query_exists(null);
    } catch (e) {
        return false;
    }
}

/**
 * Environment object
 */
const Environment = GObject.registerClass({
    GTypeName: 'Environment',
}, class Environment extends GObject.Object {

    get productVersion() {
        if (this._productVersion === undefined) {
            const tmp = readFile('/sys/class/dmi/id/product_version');
            if (tmp) {
                // Remove non-alphanumeric characters
                const sanitize = /([^ A-Za-z0-9])+/g;
                this._productVersion = tmp.replace(sanitize, '');
            } else {
                this._productVersion = null;
            }
        }
        return this._productVersion;
    }

    get kernelRelease() {
        if (this._kernelRelease === undefined) {
            try {
                let proc = Gio.Subprocess.new(
                    ['uname', '-r'],
                    Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
                );
                const [ok, stdout, stderr] = proc.communicate_utf8(null, null);
                proc = null;
                this._kernelRelease = stdout.trim();
            } catch (e) {
                logError(e);
            }
        }
        return this._kernelRelease;
    }

    get kernelMajorVersion() {
        if (this._kernelMajorVersion === undefined) {
            [this._kernelMajorVersion, this._kernelMinorVersion] = this.kernelRelease.split('.', 2);
        }
        return this._kernelMajorVersion;
    }

    get kernelMinorVersion() {
        if (this._kernelMinorVersion === undefined) {
            [this._kernelMajorVersion, this._kernelMinorVersion] = this.kernelRelease.split('.', 2);
        }
        return this._kernelMinorVersion;
    }

    checkMinKernelVersion(major, minor) {
        return (
            this.kernelMajorVersion > major ||
            (this.kernelMajorVersion === major && this.kernelMinorVersion >= minor)
        );
    }

});

/**
 * Execute a command and generate events based on its result
 * 
 * @param {string} command Command to execute
 * @param {boolean} asRoot True to run with elevated permissions
 */
const Runnable = GObject.registerClass({
    GTypeName: 'Runnable',
    Signals: {
        'command-completed': {
            param_types: [GObject.TYPE_JSOBJECT /* error */]
        }
    }
}, class Runnable extends GObject.Object {
    constructor(command, asRoot = false) {
        super();
        if (!command) {
            throw Error('The command cannot be an empty string');
        }
        this._command = command;
        this._asRoot = asRoot;
    }

    run() {
        const argv = ['sh', '-c', this._command];

        if (this._asRoot) {
            argv.unshift('pkexec');
        }

        try {
            const [, pid] = GLib.spawn_async(null, argv, null, GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD, null);

            GLib.child_watch_add(GLib.PRIORITY_DEFAULT_IDLE, pid, (pid, status) => {
                try {
                    GLib.spawn_check_exit_status(status);
                    this.emit('command-completed', null);
                } catch (e) {
                    if (e.code == 126) {
                        // Cancelled
                    } else {
                        logError(e);
                        this.emit('command-completed', e);
                    }
                }
                GLib.spawn_close_pid(pid);
            });
        } catch (e) {
            logError(e);
            this.emit('command-completed', e);
        }
    }
});

/**
 * ThinkPad battery object
 */
export const ThinkPadBattery = GObject.registerClass({
    GTypeName: 'ThinkPadBattery',
    Properties: {
        'environment': GObject.ParamSpec.object(
            'environment',
            'Environment',
            'Environment',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
            Environment.$gtype
        ),
        'name': GObject.ParamSpec.string(
            'name',
            'Name',
            'Battery name',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
            null
        ),
        'auth-required': GObject.ParamSpec.boolean(
            'auth-required',
            'Auth required',
            'Authorization is required to write the values',
            GObject.ParamFlags.READABLE,
            false
        ),
        'is-active': GObject.ParamSpec.boolean(
            'is-active',
            'Is active',
            'Indicates if the thresholds are active or not',
            GObject.ParamFlags.READABLE,
            false
        ),
        'is-available': GObject.ParamSpec.boolean(
            'is-available',
            'Is available',
            'Indicates if the battery are available or not',
            GObject.ParamFlags.READABLE,
            false
        ),
        'pending-changes': GObject.ParamSpec.boolean(
            'pending-changes',
            'Pending changes',
            'Indicates if the current values do not match the configured values',
            GObject.ParamFlags.READABLE,
            false
        ),
        'start-value': GObject.ParamSpec.int(
            'start-value',
            'Start value',
            'Current start value',
            GObject.ParamFlags.READABLE,
            0, 100,
            0
        ),
        'end-value': GObject.ParamSpec.int(
            'end-value',
            'End value',
            'Current end value',
            GObject.ParamFlags.READABLE,
            0, 100,
            100
        ),
        'settings': GObject.ParamSpec.object(
            'settings',
            'Settings',
            'Settings',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
            Gio.Settings.$gtype
        ),
    },
    Signals: {
        'enable-completed': {
            param_types: [GObject.TYPE_JSOBJECT /* error */]
        },
        'disable-completed': {
            param_types: [GObject.TYPE_JSOBJECT /* error */]
        },
    },
}, class ThinkPadBattery extends GObject.Object {
    constructor(constructProperties = {}) {
        super(constructProperties);

        if (!this.name) {
            throw Error('Battery name not defined');
        }

        // Battery directory
        this._baseDirectoryPath = `${BASE_PATH}/${this.name}`;
        this._baseDirectory = Gio.File.new_for_path(this._baseDirectoryPath);

        // Set paths
        if (this.environment.checkMinKernelVersion(5, 9)) { // kernel 5.9 and newer
            this._startFilePath = `${BASE_PATH}/${this.name}/${START_FILE_NEW}`;
            this._endFilePath = `${BASE_PATH}/${this.name}/${END_FILE_NEW}`;
        } else if (this.environment.checkMinKernelVersion(4, 17)) { // kernel 4.17 and newer
            this._startFilePath = `${BASE_PATH}/${this.name}/${START_FILE_OLD}`;
            this._endFilePath = `${BASE_PATH}/${this.name}/${END_FILE_OLD}`;
        } else { // Unsupported kernel
            throw Error(`Unsupported kernel version (${this.environment.kernelRelease}). A kernel version greater than or equal to 4.17 is required.`);
        }

        // Activate the directory monitor
        try {
            this._baseMonitor = this._baseDirectory.monitor_directory(Gio.FileMonitorFlags.NONE, null);
            this._monitorId = this._baseMonitor.connect(
                'changed', (obj, file, otherFile, eventType) => {
                    const filePath = file.get_path();
                    switch (eventType) {
                        case Gio.FileMonitorEvent.CHANGES_DONE_HINT:
                        case Gio.FileMonitorEvent.CREATED:
                        case Gio.FileMonitorEvent.DELETED:
                            switch (filePath) {
                                case this._startFilePath:
                                    this.startValue = readFileInt(this._startFilePath);
                                    break;
                                case this._endFilePath:
                                    this.endValue = readFileInt(this._endFilePath);
                                    break;
                                case this._baseDirectoryPath:
                                    this.startValue = readFileInt(this._startFilePath);
                                    this.endValue = readFileInt(this._endFilePath);
                                    break;
                                default:
                                    break;
                            }
                            break;
                        case Gio.FileMonitorEvent.ATTRIBUTE_CHANGED:
                            this.authRequired = this._checkAuthRequired();
                            break;
                        default:
                            break;
                    }
                }
            );
        } catch (e) {
            logError(e, this.name);
        }

        this._notifyHandlerId = this.connect('notify', () => this._updateStatuses());

        this._settingsHandlerId = this.settings.connect('changed', () => this._updateStatuses());

        // Load initial values
        this.startValue = readFileInt(this._startFilePath);
        this.endValue = readFileInt(this._endFilePath);
        this.authRequired = this._checkAuthRequired();
    }

    /**
     * Update the statuses
     */
    _updateStatuses() {
        // Check if is available
        this.isAvailable = this.startValue !== null || this.endValue !== null;

        // Check if thresholds is active
        const disabledStartValue = this.settings.get_int(`disabled-start-${this.name.toLowerCase()}-value`);
        if (disabledStartValue === -1) {
            // Use model detection
            if (this.environment.productVersion.search(ALTERNATIVE_DISABLED_START) >= 0) {
                this.isActive = (this.startValue !== null && this.startValue !== 95) || (this.endValue !== null && this.endValue !== 100);
            } else {
                this.isActive = (this.startValue !== null && this.startValue !== 0) || (this.endValue !== null && this.endValue !== 100);
            }
        } else {
            // Use user settings
            this.isActive = (this.startValue !== null && this.startValue !== disabledStartValue) || (this.endValue !== null && this.endValue !== 100);
        }

        const startSetting = this.settings.get_int(`start-${this.name.toLowerCase()}`);
        const endSetting = this.settings.get_int(`end-${this.name.toLowerCase()}`);
        if (this.isActive) {
            this.pendingChanges = (this.startValue !== null && this.startValue !== startSetting) || (this.endValue !== null && this.endValue !== endSetting);
        } else {
            this.pendingChanges = false;
        }

    }

    /**
     * Check if authentication is required to apply the changes
     * 
     * @returns {boolean} Returns true if authentication is required to apply the changes
     */
    _checkAuthRequired() {
        try {
            const f = Gio.File.new_for_path(this._startFilePath);
            const info = f.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
            if (!info.get_attribute_boolean('access::can-write')) {
                return true;
            }
        } catch (e) {
            // Ignored
        }

        try {
            const f = Gio.File.new_for_path(this._endFilePath);
            const info = f.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
            if (!info.get_attribute_boolean('access::can-write')) {
                return true;
            }
        } catch (e) {
            // Ignored
        }
        return false;
    }

    get authRequired() {
        return this._authRequired;
    }

    set authRequired(value) {
        if (this.authRequired !== value) {
            this._authRequired = value;
            this.notify('auth-required');
        }
    }

    get isActive() {
        return this._isActive;
    }

    set isActive(value) {
        if (this.isActive !== value) {
            this._isActive = value;
            this.notify('is-active');
        }
    }

    get isAvailable() {
        return this._isAvailable;
    }

    set isAvailable(value) {
        if (this.isAvailable !== value) {
            this._isAvailable = value;
            this.notify('is-available');
        }
    }

    get startValue() {
        return this._startValue;
    }

    set startValue(value) {
        if (this.startValue !== value) {
            this._startValue = value;
            this.notify('start-value');
        }
    }

    get endValue() {
        return this._endValue;
    }

    set endValue(value) {
        if (this.endValue !== value) {
            this._endValue = value;
            this.notify('end-value');
        }
    }

    get pendingChanges() {
        return this._pendingChanges;
    }

    set pendingChanges(value) {
        if (this.pendingChanges !== value) {
            this._pendingChanges = value;
            this.notify('pending-changes');
        }
    }

    /** 
     * Returns the command to set the thresholds.
     * This function does not run the command, it just returns the string corresponding to the command.
     * Passed values are not validated by the function, it is your responsibility to validate them first.
     * 
     * @param {number} start Start value
     * @param {number} end End value
     * @returns {string|null} Command to modify the thresholds
    */
    _getSetterCommand(start, end) {
        if (!this.isAvailable) {
            return null;
        }

        if (!Number.isInteger(start) || !Number.isInteger(end)) {
            throw TypeError(`Thresholds (${start}/${end}) on battery (${this.name}) are not integer`);
        }

        if (start < 0 || end > 100 || start >= end) {
            throw RangeError(`Thresholds (${start}/${end}) on battery (${this.name}) out of range`);
        }

        // Commands
        const setStart = `echo ${start.toString()} > ${this._startFilePath}`;
        const setEnd = `echo ${end.toString()} > ${this._endFilePath}`;

        let oldStart = this.startValue;
        let oldEnd = this.endValue;

        if ((oldStart === start || oldStart === null) && (oldEnd === end || oldEnd === null)) {
            // Same thresholds
            return null;
        }

        if (oldStart >= oldEnd) {
            // Invalid threshold reading, happens on ThinkPad E/L series
            oldStart = null;
            oldEnd = null;
        }

        let command = null;

        if (fileExists(this._startFilePath) && fileExists(this._endFilePath)) {
            if (oldStart === start) { // Same start, apply only stop
                command = setEnd;
            } else if (oldEnd === end) { // Same stop, apply only start
                command = setStart;
            } else {
                // Determine sequence
                let startStopSequence = true;
                if (oldEnd != null && start > oldEnd) {
                    startStopSequence = false;
                }
                if (startStopSequence) {
                    command = setStart + ' && ' + setEnd;
                } else {
                    command = setEnd + ' && ' + setStart;
                }
            }
        } else if (fileExists(this._startFilePath)) { // Only start available
            command = setStart;
        } else if (fileExists(this._endFilePath)) { // Only stop available
            command = setEnd;
        }

        return command;
    }

    /**
     * Get the command string to enable the configured thresholds
     * 
     * @returns {string|null} Enable thresholds command string
     */
    get enableCommand() {
        const startSetting = this.settings.get_int(`start-${this.name.toLowerCase()}`);
        const endSetting = this.settings.get_int(`end-${this.name.toLowerCase()}`);
        return this._getSetterCommand(startSetting, endSetting);
    }

    /**
     * Get the command string to disable the thresholds
     * 
     * @returns {string|null} Enable thresholds command string
     */
    get disableCommand() {
        return this._getSetterCommand(0, 100);
    }

    /**
     * Enable the configured thresholds
     */
    enable() {
        const command = this.enableCommand;
        if (!command) {
            return;
        }
        const runnable = new Runnable(command, this.authRequired);
        runnable.connect('command-completed', (obj, error) => {
            this.emit('enable-completed', error);
        })
        runnable.run();
    }

    /**
     * Disable thresholds
     */
    disable() {
        const command = this.disableCommand;
        if (!command) {
            return;
        }
        const runnable = new Runnable(command, this.authRequired);
        runnable.connect('command-completed', (obj, error) => {
            this.emit('disable-completed', error);
        })
        runnable.run();
    }

    /**
     * Toggle thresholds status
     */
    toggle() {
        this.isActive ? this.disable() : this.enable();
    }

    destroy() {
        if (this._notifyHandlerId) {
            this.disconnect(this._notifyHandlerId)
        }
        if (this._settingsHandlerId) {
            this.settings.disconnect(this._settingsHandlerId)
        }
        this._baseMonitor.cancel();
        this._baseMonitor = null;
        this._baseDirectory = null;
    }
});

export const ThinkPad = GObject.registerClass({
    GTypeName: 'ThinkPad',
    Properties: {
        'environment': GObject.ParamSpec.object(
            'environment',
            'Environment',
            'Environment',
            GObject.ParamFlags.READABLE,
            Environment.$gtype
        ),
        'is-active': GObject.ParamSpec.boolean(
            'is-active',
            'Is active',
            'Indicates if the thresholds are active or not',
            GObject.ParamFlags.READABLE,
            false
        ),
        'is-available': GObject.ParamSpec.boolean(
            'is-available',
            'Is available',
            'Indicates if the battery are available or not',
            GObject.ParamFlags.READABLE,
            false
        ),
        'pending-changes': GObject.ParamSpec.boolean(
            'pending-changes',
            'Pending changes',
            'Indicates if the current values do not match the configured values',
            GObject.ParamFlags.READABLE,
            false
        ),
        'batteries': GObject.ParamSpec.jsobject(
            'batteries',
            'Batteries',
            'Batteries object',
            GObject.ParamFlags.READWRITE,
            []
        ),
        'settings': GObject.ParamSpec.object(
            'settings',
            'Settings',
            'Settings',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
            Gio.Settings.$gtype
        ),
    },
    Signals: {
        'enable-battery-completed': {
            param_types: [GObject.TYPE_OBJECT /* battery */, GObject.TYPE_JSOBJECT /* error */]
        },
        'disable-battery-completed': {
            param_types: [GObject.TYPE_OBJECT /* battery */, GObject.TYPE_JSOBJECT /* error */]
        },
        'enable-all-completed': {
            param_types: [GObject.TYPE_JSOBJECT /* error */]
        },
        'disable-all-completed': {
            param_types: [GObject.TYPE_JSOBJECT /* error */]
        },
    },
}, class ThinkPad extends GObject.Object {
    constructor(constructProperties = {}) {
        super(constructProperties);

        if (!this.environment.checkMinKernelVersion(4, 17)) {
            logError(new Error(`Kernel ${this.environment.kernelRelease} not supported`));
            this.batteries = [];
            return;
        }

        // Signals handlers IDs
        this._batteriesId = {};

        // Define batteries
        this.batteries = [
            new ThinkPadBattery({
                'environment': this.environment,
                'name': 'BAT0',
                'settings': this.settings
            }),
            new ThinkPadBattery({
                'environment': this.environment,
                'name': 'BAT1',
                'settings': this.settings
            }),
        ];

        // Connect the signals from the batteries to update the flags and notify commands
        this.batteries.forEach(battery => {
            const notifyHandlerId = battery.connect('notify', () => this._updateStatuses());

            const enableCompletedId = battery.connect(
                'enable-completed', (bat, error) => {
                    this.emit('enable-battery-completed', bat, error);
                }
            );
            const disableCompletedId = battery.connect(
                'disable-completed', (bat, error) => {
                    this.emit('disable-battery-completed', bat, error);
                }
            );
            this._batteriesId[battery.name] = [notifyHandlerId, enableCompletedId, disableCompletedId];
        });

        // Load initial values
        this._updateStatuses();
    }

    get environment() {
        if (this._environment === undefined) {
            this._environment = new Environment();
        }
        return this._environment;
    }

    get isAvailable() {
        return this._isAvailable;
    }

    set isAvailable(value) {
        if (this.isAvailable !== value) {
            this._isAvailable = value;
            this.notify('is-available');
        }
    }

    get isActive() {
        return this._isActive;
    }

    set isActive(value) {
        if (this.isActive !== value) {
            this._isActive = value;
            this.notify('is-active');
        }
    }

    get pendingChanges() {
        return this._pendingChanges;
    }

    set pendingChanges(value) {
        if (this.pendingChanges !== value) {
            this._pendingChanges = value;
            this.notify('pending-changes');
        }
    }

    /**
     * Update driver status
     */
    _updateStatuses() {
        this.isAvailable = this.batteries.some(bat => {
            return bat.isAvailable;
        });
        this.isActive = this.batteries.some(bat => {
            return bat.isActive && bat.isAvailable;
        });
        this.pendingChanges = this.batteries.some(bat => {
            return bat.pendingChanges;
        });
    }

    /**
     * Enable all configured thresholds
     */
    enableAll() {
        let command = '';
        let authRequired = false;
        this.batteries.forEach(battery => {
            const batteryCommand = battery.enableCommand;
            if (batteryCommand) {
                command = `${command}${command ? ' && ' : ''}${batteryCommand}`;
                authRequired = authRequired || battery.authRequired;
            }
        });
        if (!command) {
            return;
        }
        const runnable = new Runnable(command, authRequired);
        runnable.connect('command-completed', (obj, error) => {
            this.emit('enable-all-completed', error);
        })
        runnable.run();
    }

    /**
     * Disable all thresholds
     */
    disableAll() {
        let command = '';
        let authRequired = false;
        this.batteries.forEach(battery => {
            const batteryCommand = battery.disableCommand;
            if (batteryCommand) {
                command = `${command}${command ? ' && ' : ''}${batteryCommand}`;
                authRequired = authRequired || battery.authRequired;
            }
        });
        if (!command) {
            return;
        }
        const runnable = new Runnable(command, authRequired);
        runnable.connect('command-completed', (obj, error) => {
            this.emit('disable-all-completed', error);
        })
        runnable.run();
    }

    destroy() {
        if (this._batteriesId) {
            this.batteries.forEach(battery => {
                const ids = this._batteriesId[battery.name];
                ids.forEach(id => {
                    battery.disconnect(id);
                });
                battery.destroy()
            });
        }
    }
});