// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later

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

import * as Components from '../components/index.js';
import Config from '../../config.js';
import * as DBus from '../utils/dbus.js';
import  {Player} from '../components/mpris.js';
import Plugin from '../plugin.js';


export const Metadata = {
    label: _('MPRIS'),
    description: _('Bidirectional remote media playback control'),
    id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.MPRIS',
    incomingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
    outgoingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
    actions: {},
};


/**
 * MPRIS Plugin
 * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mpriscontrol
 *
 * See also:
 *     https://specifications.freedesktop.org/mpris-spec/latest/
 *     https://github.com/GNOME/gnome-shell/blob/master/js/ui/mpris.js
 */
const MPRISPlugin = GObject.registerClass({
    GTypeName: 'GSConnectMPRISPlugin',
}, class MPRISPlugin extends Plugin {

    _init(device) {
        super._init(device, 'mpris');

        this._players = new Map();
        this._transferring = new WeakSet();
        this._updating = new WeakSet();

        this._mpris = Components.acquire('mpris');

        this._playerAddedId = this._mpris.connect(
            'player-added',
            this._sendPlayerList.bind(this)
        );

        this._playerRemovedId = this._mpris.connect(
            'player-removed',
            this._sendPlayerList.bind(this)
        );

        this._playerChangedId = this._mpris.connect(
            'player-changed',
            this._onPlayerChanged.bind(this)
        );

        this._playerSeekedId = this._mpris.connect(
            'player-seeked',
            this._onPlayerSeeked.bind(this)
        );
    }

    connected() {
        super.connected();

        this._requestPlayerList();
        this._sendPlayerList();
    }

    disconnected() {
        super.disconnected();

        for (const [identity, player] of this._players) {
            this._players.delete(identity);
            player.destroy();
        }
    }

    handlePacket(packet) {
        switch (packet.type) {
            case 'kdeconnect.mpris':
                this._handleUpdate(packet);
                break;

            case 'kdeconnect.mpris.request':
                this._handleRequest(packet);
                break;
        }
    }

    /**
     * Handle a remote player update.
     *
     * @param {Core.Packet} packet - A `kdeconnect.mpris`
     */
    _handleUpdate(packet) {
        try {
            if (packet.body.hasOwnProperty('playerList'))
                this._handlePlayerList(packet.body.playerList);
            else if (packet.body.hasOwnProperty('player'))
                this._handlePlayerUpdate(packet);
        } catch (e) {
            debug(e, this.device.name);
        }
    }

    /**
     * Handle an updated list of remote players.
     *
     * @param {string[]} playerList - A list of remote player names
     */
    _handlePlayerList(playerList) {
        // Destroy removed players before adding new ones
        for (const player of this._players.values()) {
            if (!playerList.includes(player.Identity)) {
                this._players.delete(player.Identity);
                player.destroy();
            }
        }

        for (const identity of playerList) {
            if (!this._players.has(identity)) {
                const player = new PlayerRemote(this.device, identity);
                this._players.set(identity, player);
            }

            // Always request player updates; packets are cheap
            this.device.sendPacket({
                type: 'kdeconnect.mpris.request',
                body: {
                    player: identity,
                    requestNowPlaying: true,
                    requestVolume: true,
                },
            });
        }
    }

    /**
     * Handle an update for a remote player.
     *
     * @param {Object} packet - A `kdeconnect.mpris` packet
     */
    _handlePlayerUpdate(packet) {
        const player = this._players.get(packet.body.player);

        if (player === undefined)
            return;

        if (packet.body.hasOwnProperty('transferringAlbumArt'))
            player.handleAlbumArt(packet);
        else
            player.update(packet.body);
    }

    /**
     * Request a list of remote players.
     */
    _requestPlayerList() {
        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                requestPlayerList: true,
            },
        });
    }

    /**
     * Handle a request for player information or action.
     *
     * @param {Core.Packet} packet - a `kdeconnect.mpris.request`
     * @return {undefined} no return value
     */
    _handleRequest(packet) {
        // A request for the list of players
        if (packet.body.hasOwnProperty('requestPlayerList'))
            return this._sendPlayerList();

        // A request for an unknown player; send the list of players
        if (!this._mpris.hasPlayer(packet.body.player))
            return this._sendPlayerList();

        // An album art request
        if (packet.body.hasOwnProperty('albumArtUrl'))
            return this._sendAlbumArt(packet);

        // A player command
        this._handleCommand(packet);
    }

    /**
     * Handle an incoming player command or information request
     *
     * @param {Core.Packet} packet - A `kdeconnect.mpris.request`
     */
    async _handleCommand(packet) {
        if (!this.settings.get_boolean('share-players'))
            return;

        let player;

        try {
            player = this._mpris.getPlayer(packet.body.player);

            if (player === undefined || this._updating.has(player))
                return;

            this._updating.add(player);

            // Player Actions
            if (packet.body.hasOwnProperty('action')) {
                switch (packet.body.action) {
                    case 'PlayPause':
                    case 'Play':
                    case 'Pause':
                    case 'Next':
                    case 'Previous':
                    case 'Stop':
                        player[packet.body.action]();
                        break;

                    default:
                        debug(`unknown action: ${packet.body.action}`);
                }
            }

            // Player Properties
            if (packet.body.hasOwnProperty('setLoopStatus'))
                player.LoopStatus = packet.body.setLoopStatus;

            if (packet.body.hasOwnProperty('setShuffle'))
                player.Shuffle = packet.body.setShuffle;

            if (packet.body.hasOwnProperty('setVolume'))
                player.Volume = packet.body.setVolume / 100;

            if (packet.body.hasOwnProperty('Seek'))
                await player.Seek(packet.body.Seek);

            if (packet.body.hasOwnProperty('SetPosition')) {
                // We want to avoid implementing this as a seek operation,
                // because some players seek a fixed amount for every
                // seek request, only respecting the sign of the parameter.
                // (Chrome, for example, will only seek ±5 seconds, regardless
                // what value is passed to Seek().)
                const position = packet.body.SetPosition;
                const metadata = player.Metadata;
                if (metadata.hasOwnProperty('mpris:trackid')) {
                    const trackId = metadata['mpris:trackid'];
                    await player.SetPosition(trackId, position * 1000);
                } else {
                    await player.Seek(position * 1000 - player.Position);
                }
            }

            // Information Request
            let hasResponse = false;

            const response = {
                type: 'kdeconnect.mpris',
                body: {
                    player: packet.body.player,
                },
            };

            if (packet.body.hasOwnProperty('requestNowPlaying')) {
                hasResponse = true;

                Object.assign(response.body, {
                    pos: Math.floor(player.Position / 1000),
                    isPlaying: (player.PlaybackStatus === 'Playing'),
                    canPause: player.CanPause,
                    canPlay: player.CanPlay,
                    canGoNext: player.CanGoNext,
                    canGoPrevious: player.CanGoPrevious,
                    canSeek: player.CanSeek,
                    loopStatus: player.LoopStatus,
                    shuffle: player.Shuffle,

                    // default values for members that will be filled conditionally
                    albumArtUrl: '',
                    length: 0,
                    artist: '',
                    title: '',
                    album: '',
                    nowPlaying: '',
                    volume: 0,
                });

                const metadata = player.Metadata;

                if (metadata.hasOwnProperty('mpris:artUrl')) {
                    const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
                    response.body.albumArtUrl = file.get_uri();
                }

                if (metadata.hasOwnProperty('mpris:length')) {
                    const trackLen = Math.floor(metadata['mpris:length'] / 1000);
                    response.body.length = trackLen;
                }

                if (metadata.hasOwnProperty('xesam:artist')) {
                    const artists = metadata['xesam:artist'];
                    response.body.artist = artists.join(', ');
                }

                if (metadata.hasOwnProperty('xesam:title'))
                    response.body.title = metadata['xesam:title'];

                if (metadata.hasOwnProperty('xesam:album'))
                    response.body.album = metadata['xesam:album'];

                // Now Playing
                if (response.body.artist && response.body.title) {
                    response.body.nowPlaying = [
                        response.body.artist,
                        response.body.title,
                    ].join(' - ');
                } else if (response.body.artist) {
                    response.body.nowPlaying = response.body.artist;
                } else if (response.body.title) {
                    response.body.nowPlaying = response.body.title;
                } else {
                    response.body.nowPlaying = _('Unknown');
                }
            }

            if (packet.body.hasOwnProperty('requestVolume')) {
                hasResponse = true;
                response.body.volume = Math.floor(player.Volume * 100);
            }

            if (hasResponse)
                this.device.sendPacket(response);
        } catch (e) {
            debug(e, this.device.name);
        } finally {
            this._updating.delete(player);
        }
    }

    _onPlayerChanged(mpris, player) {
        if (!this.settings.get_boolean('share-players'))
            return;

        this._handleCommand({
            body: {
                player: player.Identity,
                requestNowPlaying: true,
                requestVolume: true,
            },
        });
    }

    _onPlayerSeeked(mpris, player, offset) {
        // TODO: although we can handle full seeked signals, kdeconnect-android
        //       does not, and expects a position update instead
        this.device.sendPacket({
            type: 'kdeconnect.mpris',
            body: {
                player: player.Identity,
                pos: Math.floor(player.Position / 1000),
                // Seek: Math.floor(offset / 1000),
            },
        });
    }

    async _sendAlbumArt(packet) {
        let player;

        try {
            // Reject concurrent requests for album art
            player = this._mpris.getPlayer(packet.body.player);

            if (player === undefined || this._transferring.has(player))
                return;

            // Ensure the requested albumArtUrl matches the current mpris:artUrl
            const metadata = player.Metadata;

            if (!metadata.hasOwnProperty('mpris:artUrl'))
                return;

            const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
            const request = Gio.File.new_for_uri(packet.body.albumArtUrl);

            if (file.get_uri() !== request.get_uri())
                throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`);

            // Transfer the album art
            this._transferring.add(player);

            const transfer = this.device.createTransfer();

            transfer.addFile({
                type: 'kdeconnect.mpris',
                body: {
                    transferringAlbumArt: true,
                    player: packet.body.player,
                    albumArtUrl: packet.body.albumArtUrl,
                },
            }, file);

            await transfer.start();
        } catch (e) {
            debug(e, this.device.name);
        } finally {
            this._transferring.delete(player);
        }
    }

    /**
     * Send the list of player identities and indicate whether we support
     * transferring album art
     */
    _sendPlayerList() {
        let playerList = [];

        if (this.settings.get_boolean('share-players'))
            playerList = this._mpris.getIdentities();

        this.device.sendPacket({
            type: 'kdeconnect.mpris',
            body: {
                playerList: playerList,
                supportAlbumArtPayload: true,
            },
        });
    }

    destroy() {
        if (this._mpris !== undefined) {
            this._mpris.disconnect(this._playerAddedId);
            this._mpris.disconnect(this._playerRemovedId);
            this._mpris.disconnect(this._playerChangedId);
            this._mpris.disconnect(this._playerSeekedId);
            this._mpris = Components.release('mpris');
        }

        for (const [identity, player] of this._players) {
            this._players.delete(identity);
            player.destroy();
        }

        super.destroy();
    }
});


/*
 * A class for mirroring a remote Media Player on DBus
 */
const PlayerRemote = GObject.registerClass({
    GTypeName: 'GSConnectMPRISPlayerRemote',
}, class PlayerRemote extends Player {

    _init(device, identity) {
        super._init();

        this._device = device;
        this._Identity = identity;
        this._isPlaying = false;

        this._artist = null;
        this._title = null;
        this._album = null;
        this._length = 0;
        this._artUrl = null;

        this._ownerId = 0;
        this._connection = null;
        this._applicationIface = null;
        this._playerIface = null;
    }

    _getFile(albumArtUrl) {
        const hash = GLib.compute_checksum_for_string(GLib.ChecksumType.MD5,
            albumArtUrl, -1);
        const path = GLib.build_filenamev([Config.CACHEDIR, hash]);

        return Gio.File.new_for_uri(`file://${path}`);
    }

    _requestAlbumArt(state) {
        if (this._artUrl === state.albumArtUrl)
            return;

        const file = this._getFile(state.albumArtUrl);

        if (file.query_exists(null)) {
            this._artUrl = file.get_uri();
            this._Metadata = undefined;
            this.notify('Metadata');
        } else {
            this.device.sendPacket({
                type: 'kdeconnect.mpris.request',
                body: {
                    player: this.Identity,
                    albumArtUrl: state.albumArtUrl,
                },
            });
        }
    }

    _updateMetadata(state) {
        let metadataChanged = false;

        if (state.hasOwnProperty('artist')) {
            if (this._artist !== state.artist) {
                this._artist = state.artist;
                metadataChanged = true;
            }
        } else if (this._artist) {
            this._artist = null;
            metadataChanged = true;
        }

        if (state.hasOwnProperty('title')) {
            if (this._title !== state.title) {
                this._title = state.title;
                metadataChanged = true;
            }
        } else if (this._title) {
            this._title = null;
            metadataChanged = true;
        }

        if (state.hasOwnProperty('album')) {
            if (this._album !== state.album) {
                this._album = state.album;
                metadataChanged = true;
            }
        } else if (this._album) {
            this._album = null;
            metadataChanged = true;
        }

        if (state.hasOwnProperty('length')) {
            if (this._length !== state.length * 1000) {
                this._length = state.length * 1000;
                metadataChanged = true;
            }
        } else if (this._length) {
            this._length = 0;
            metadataChanged = true;
        }

        if (state.hasOwnProperty('albumArtUrl')) {
            this._requestAlbumArt(state);
        } else if (this._artUrl) {
            this._artUrl = null;
            metadataChanged = true;
        }

        if (metadataChanged) {
            this._Metadata = undefined;
            this.notify('Metadata');
        }
    }

    async export() {
        try {
            if (this._connection === null) {
                this._connection = await DBus.newConnection();
                const MPRISIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2');
                const MPRISPlayerIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2.Player');

                if (this._applicationIface === null) {
                    this._applicationIface = new DBus.Interface({
                        g_instance: this,
                        g_connection: this._connection,
                        g_object_path: '/org/mpris/MediaPlayer2',
                        g_interface_info: MPRISIface,
                    });
                }

                if (this._playerIface === null) {
                    this._playerIface = new DBus.Interface({
                        g_instance: this,
                        g_connection: this._connection,
                        g_object_path: '/org/mpris/MediaPlayer2',
                        g_interface_info: MPRISPlayerIface,
                    });
                }
            }

            if (this._ownerId !== 0)
                return;

            const name = [
                this.device.name,
                this.Identity,
            ].join('').replace(/[\W]*/g, '');

            this._ownerId = Gio.bus_own_name_on_connection(
                this._connection,
                `org.mpris.MediaPlayer2.GSConnect.${name}`,
                Gio.BusNameOwnerFlags.NONE,
                null,
                null
            );
        } catch (e) {
            debug(e, this.Identity);
        }
    }

    unexport() {
        if (this._ownerId === 0)
            return;

        Gio.bus_unown_name(this._ownerId);
        this._ownerId = 0;
    }

    /**
     * Download album art for the current track of the remote player.
     *
     * @param {Core.Packet} packet - A `kdeconnect.mpris` packet
     */
    async handleAlbumArt(packet) {
        let file;

        try {
            file = this._getFile(packet.body.albumArtUrl);

            // Transfer the album art
            const transfer = this.device.createTransfer();
            transfer.addFile(packet, file);

            await transfer.start();

            this._artUrl = file.get_uri();
            this._Metadata = undefined;
            this.notify('Metadata');
        } catch (e) {
            debug(e, this.device.name);

            if (file)
                file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
        }
    }

    /**
     * Update the internal state of the media player.
     *
     * @param {Core.Packet} state - The body of a `kdeconnect.mpris` packet
     */
    update(state) {
        this.freeze_notify();

        // Metadata
        if (state.hasOwnProperty('nowPlaying') ||
            state.hasOwnProperty('artist') ||
            state.hasOwnProperty('title'))
            this._updateMetadata(state);

        // Playback Status
        if (state.hasOwnProperty('isPlaying')) {
            if (this._isPlaying !== state.isPlaying) {
                this._isPlaying = state.isPlaying;
                this.notify('PlaybackStatus');
            }
        }

        if (state.hasOwnProperty('canPlay')) {
            if (this.CanPlay !== state.canPlay) {
                this._CanPlay = state.canPlay;
                this.notify('CanPlay');
            }
        }

        if (state.hasOwnProperty('canPause')) {
            if (this.CanPause !== state.canPause) {
                this._CanPause = state.canPause;
                this.notify('CanPause');
            }
        }

        if (state.hasOwnProperty('canGoNext')) {
            if (this.CanGoNext !== state.canGoNext) {
                this._CanGoNext = state.canGoNext;
                this.notify('CanGoNext');
            }
        }

        if (state.hasOwnProperty('canGoPrevious')) {
            if (this.CanGoPrevious !== state.canGoPrevious) {
                this._CanGoPrevious = state.canGoPrevious;
                this.notify('CanGoPrevious');
            }
        }

        if (state.hasOwnProperty('pos'))
            this._Position = state.pos * 1000;

        if (state.hasOwnProperty('volume')) {
            if (this.Volume !== state.volume / 100) {
                this._Volume = state.volume / 100;
                this.notify('Volume');
            }
        }

        this.thaw_notify();

        if (!this._isPlaying && !this.CanControl)
            this.unexport();
        else
            this.export();
    }

    /*
     * Native properties
     */
    get device() {
        return this._device;
    }

    /*
     * The org.mpris.MediaPlayer2.Player Interface
     */
    get CanControl() {
        return (this.CanPlay || this.CanPause);
    }

    get Metadata() {
        if (this._Metadata === undefined) {
            this._Metadata = {};

            if (this._artist) {
                this._Metadata['xesam:artist'] = new GLib.Variant('as',
                    [this._artist]);
            }

            if (this._title) {
                this._Metadata['xesam:title'] = new GLib.Variant('s',
                    this._title);
            }

            if (this._album) {
                this._Metadata['xesam:album'] = new GLib.Variant('s',
                    this._album);
            }

            if (this._artUrl) {
                this._Metadata['mpris:artUrl'] = new GLib.Variant('s',
                    this._artUrl);
            }

            this._Metadata['mpris:length'] = new GLib.Variant('x',
                this._length);
        }

        return this._Metadata;
    }

    get PlaybackStatus() {
        if (this._isPlaying)
            return 'Playing';

        return 'Stopped';
    }

    get Volume() {
        if (this._Volume === undefined)
            this._Volume = 0.3;

        return this._Volume;
    }

    set Volume(level) {
        if (this._Volume === level)
            return;

        this._Volume = level;
        this.notify('Volume');

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                setVolume: Math.floor(this._Volume * 100),
            },
        });
    }

    Next() {
        if (!this.CanGoNext)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                action: 'Next',
            },
        });
    }

    Pause() {
        if (!this.CanPause)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                action: 'Pause',
            },
        });
    }

    Play() {
        if (!this.CanPlay)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                action: 'Play',
            },
        });
    }

    PlayPause() {
        if (!this.CanPlay && !this.CanPause)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                action: 'PlayPause',
            },
        });
    }

    Previous() {
        if (!this.CanGoPrevious)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                action: 'Previous',
            },
        });
    }

    Seek(offset) {
        if (!this.CanSeek)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                Seek: offset,
            },
        });
    }

    SetPosition(trackId, position) {
        debug(`${this._Identity}: SetPosition(${trackId}, ${position})`);

        if (!this.CanControl || !this.CanSeek)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                SetPosition: position / 1000,
            },
        });
    }

    Stop() {
        if (!this.CanControl)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.mpris.request',
            body: {
                player: this.Identity,
                action: 'Stop',
            },
        });
    }

    destroy() {
        this.unexport();

        if (this._connection) {
            this._connection.close(null, null);
            this._connection = null;

            if (this._applicationIface) {
                this._applicationIface.destroy();
                this._applicationIface = null;
            }

            if (this._playerIface) {
                this._playerIface.destroy();
                this._playerIface = null;
            }
        }
    }
});

export default MPRISPlugin;
