/* exported TransientSignalHolder, addObjectSignalMethods */
const { GObject } = imports.gi;

const destroyableTypes = [];

/**
 * @private
 * @param {Object} obj - an object
 * @returns {bool} - true if obj has a 'destroy' GObject signal
 */
function _hasDestroySignal(obj) {
    return destroyableTypes.some(type => obj instanceof type);
}

var TransientSignalHolder = GObject.registerClass(
class TransientSignalHolder extends GObject.Object {
    static [GObject.signals] = {
        'destroy': {},
    };

    constructor(owner) {
        super();

        if (_hasDestroySignal(owner))
            owner.connectObject('destroy', () => this.destroy(), this);
    }

    destroy() {
        this.emit('destroy');
    }
});
registerDestroyableType(TransientSignalHolder);

class SignalManager {
    /**
     * @returns {SignalManager} - the SignalManager singleton
     */
    static getDefault() {
        if (!this._singleton)
            this._singleton = new SignalManager();
        return this._singleton;
    }

    constructor() {
        this._signalTrackers = new Map();
    }

    /**
     * @param {Object} obj - object to get signal tracker for
     * @returns {SignalTracker} - the signal tracker for object
     */
    getSignalTracker(obj) {
        if (!this._signalTrackers.has(obj))
            this._signalTrackers.set(obj, new SignalTracker(obj));
        return this._signalTrackers.get(obj);
    }
}

class SignalTracker {
    /**
     * @param {Object=} owner - object that owns the tracker
     */
    constructor(owner) {
        if (_hasDestroySignal(owner))
            this._ownerDestroyId = owner.connect_after('destroy', () => this.clear());

        this._owner = owner;
        this._map = new Map();
    }

    /**
     * @typedef SignalData
     * @property {number[]} ownerSignals - a list of handler IDs
     * @property {number} destroyId - destroy handler ID of tracked object
     */

    /**
     * @private
     * @param {Object} obj - a tracked object
     * @returns {SignalData} - signal data for object
     */
    _getSignalData(obj) {
        if (!this._map.has(obj))
            this._map.set(obj, { ownerSignals: [], destroyId: 0 });
        return this._map.get(obj);
    }

    /**
     * @private
     * @param {GObject.Object} obj - tracked widget
     */
    _trackDestroy(obj) {
        const signalData = this._getSignalData(obj);
        if (signalData.destroyId)
            return;
        signalData.destroyId = obj.connect_after('destroy', () => this.untrack(obj));
    }

    _disconnectSignal(obj, id) {
        const proto = obj instanceof GObject.Object
            ? GObject.Object.prototype
            : Object.getPrototypeOf(obj);
        proto['disconnect'].call(obj, id);
    }

    /**
     * @param {Object} obj - tracked object
     * @param {...number} handlerIds - tracked handler IDs
     * @returns {void}
     */
    track(obj, ...handlerIds) {
        if (_hasDestroySignal(obj))
            this._trackDestroy(obj);

        this._getSignalData(obj).ownerSignals.push(...handlerIds);
    }

    /**
     * @param {Object} obj - tracked object instance
     * @returns {void}
     */
    untrack(obj) {
        const { ownerSignals, destroyId } = this._getSignalData(obj);
        this._map.delete(obj);

        ownerSignals.forEach(id => this._disconnectSignal(this._owner, id));
        if (destroyId)
            this._disconnectSignal(obj, destroyId);
    }

    /**
     * @returns {void}
     */
    clear() {
        [...this._map.keys()].forEach(obj => this.untrack(obj));
    }

    /**
     * @returns {void}
     */
    destroy() {
        this.clear();

        if (this._ownerDestroyId)
            this._disconnectSignal(this._owner, this._ownerDestroyId);

        delete this._ownerDestroyId;
        delete this._owner;
    }
}

/**
 * Connect one or more signals, and associate the handlers
 * with a tracked object.
 *
 * All handlers for a particular object can be disconnected
 * by calling disconnectObject(). If object is a {Clutter.widget},
 * this is done automatically when the widget is destroyed.
 *
 * @param {object} thisObj - the emitter object
 * @param {...any} args - a sequence of signal-name/handler pairs
 * with an optional flags value, followed by an object to track
 * @returns {void}
 */
function connectObject(thisObj, ...args) {
    const getParams = argArray => {
        const [signalName, handler, arg, ...rest] = argArray;
        if (typeof arg !== 'number')
            return [signalName, handler, 0, arg, ...rest];

        const flags = arg;
        if (flags > GObject.ConnectFlags.SWAPPED)
            throw new Error(`Invalid flag value ${flags}`);
        if (flags === GObject.ConnectFlags.SWAPPED)
            throw new Error('Swapped signals are not supported');
        return [signalName, handler, flags, ...rest];
    };

    const connectSignal = (emitter, signalName, handler, flags) => {
        const isGObject = emitter instanceof GObject.Object;
        const func = flags === GObject.ConnectFlags.AFTER && isGObject
            ? 'connect_after'
            : 'connect';
        const emitterProto = isGObject
            ? GObject.Object.prototype
            : Object.getPrototypeOf(emitter);
        return emitterProto[func].call(emitter, signalName, handler);
    };

    const signalIds = [];
    while (args.length > 1) {
        const [signalName, handler, flags, ...rest] = getParams(args);
        signalIds.push(connectSignal(thisObj, signalName, handler, flags));
        args = rest;
    }

    let [obj] = args;
    if (!obj)
        obj = globalThis;

    const tracker = SignalManager.getDefault().getSignalTracker(thisObj);
    tracker.track(obj, ...signalIds);
}

/**
 * Disconnect all signals that were connected for
 * the specified tracked object
 *
 * @param {Object} thisObj - the emitter object
 * @param {Object} obj - the tracked object
 * @returns {void}
 */
function disconnectObject(thisObj, obj) {
    SignalManager.getDefault().getSignalTracker(thisObj).untrack(obj);
}

/**
 * Add connectObject()/disconnectObject() methods
 * to prototype. The prototype must have the connect()
 * and disconnect() signal methods.
 *
 * @param {prototype} proto - a prototype
 */
function addObjectSignalMethods(proto) {
    proto['connectObject'] = function (...args) {
        connectObject(this, ...args);
    };
    proto['connect_object'] = proto['connectObject'];

    proto['disconnectObject'] = function (obj) {
        disconnectObject(this, obj);
    };
    proto['disconnect_object'] = proto['disconnectObject'];
}

/**
 * Register a GObject type as having a 'destroy' signal
 * that should disconnect all handlers
 *
 * @param {GObject.Type} gtype - a GObject type
 */
function registerDestroyableType(gtype) {
    if (!GObject.type_is_a(gtype, GObject.Object))
        throw new Error(`${gtype} is not a GObject subclass`);

    if (!GObject.signal_lookup('destroy', gtype))
        throw new Error(`${gtype} does not have a destroy signal`);

    destroyableTypes.push(gtype);
}