// Bing Wallpaper GNOME extension
// Copyright (C) 2017-2025 Michael Carroll
// This extension is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// See the GNU General Public License, version 3 or later for details.
// Based on GNOME shell extension NASA APOD by Elia Argentieri https://github.com/Elinvention/gnome-shell-extension-nasa-apod

import St from 'gi://St';
import Soup from 'gi://Soup';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import GLib from 'gi://GLib';
import Clutter from 'gi://Clutter';
import Cogl from 'gi://Cogl';

import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
import {Button} from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import * as Config from 'resource:///org/gnome/shell/misc/config.js';

import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Utils from './utils.js';
import Thumbnail from './thumbnail.js';

const BingImageURL = Utils.BingImageURL;
const BingURL = 'https://www.bing.com';
const IndicatorName = 'BingWallpaperIndicator';
const TIMEOUT_SECONDS = 24 * 3600; // FIXME: this should use the end data from the json data
const TIMEOUT_SECONDS_ON_HTTP_ERROR = 1 * 3600; // retry in one hour if there is a http error3
const MINIMUM_SHUFFLE_IMAGES = 3; // bare minimum to use filtered image set in shuffle mode
const ICON_PREVIOUS_BUTTON = 'media-seek-backward-symbolic';
const ICON_NEXT_BUTTON = 'media-seek-forward-symbolic';
const ICON_CURRENT_BUTTON = 'media-skip-forward-symbolic';

let bingWallpaperIndicator = null;

const newMenuItem = (label) => {
    return new PopupMenu.PopupMenuItem(label);
}

const newMenuSwitchItem = (label, state) => {
    let switchItem = new PopupMenu.PopupSwitchMenuItem(
        label, 
        state, 
        {});
    switchItem.label.x_expand = true;
    switchItem._statusBin.x_expand = false;
    return switchItem;
}

function BingLog(msg) {
    if (BingDebug())
        console.log('BingWallpaper extension: ' + msg); // disable to keep the noise down in journal
}

function BingDebug() {
    if (bingWallpaperIndicator && bingWallpaperIndicator._settings.get_boolean('debug-logging'))
        return true;
    return false;
}

function notifyError(msg) {
    Main.notifyError("BingWallpaper extension error", msg);
}

function doSetBackground(uri, schema) {
    let gsettings = new Gio.Settings({schema: schema});
    let prev = gsettings.get_string('picture-uri');
    uri = 'file://' + uri;
    gsettings.set_string('picture-uri', uri);
    try {
        gsettings.set_string('picture-uri-dark', uri);
    }
    catch (e) {
        BingLog("unable to set dark background for : " + e);
    }
    Gio.Settings.sync();
    gsettings.apply();
    return (prev != uri); // return true if background uri has changed
}

const BingWallpaperIndicator = GObject.registerClass(
class BingWallpaperIndicator extends Button {
    _init(ext) {
        super._init(0, IndicatorName, false);

        this.title = "";
        this.explanation = "";
        this.filename = "";
        this.copyright = "";
        this.version = "0.1";
        this._updatePending = false;
        this._timeout = null;
        this._shuffleTimeout = null;
        this.longstartdate = null;
        this.imageURL = ""; // link to image itself
        this.imageinfolink = ""; // link to Bing photo info page
        this.refreshdue = 0;
        this.shuffledue = 0;
        this.refreshduetext = "";
        this.thumbnail = null;
        this.thumbnailItem = null;
        this.selected_image = "current";
        this.imageIndex = null;
        this.logger = null;
        this.favourite_status = false;
        this.hidden_status = false;
        this.dimensions = { 'width': null, 'height': null};
        this._extension = ext;
        
        let extensionIconsPath = ext.dir.get_child('icons').get_path();
        this.ICON_RANDOM = extensionIconsPath + '/'+'game-die-symbolic.svg';
        this.ICON_FAVE_BUTTON = extensionIconsPath + '/'+'fav-symbolic.svg';
        this.ICON_UNFAVE_BUTTON = extensionIconsPath + '/'+'unfav-symbolic.svg';
        this.ICON_TRASH_BUTTON = extensionIconsPath + '/'+'trash-empty-symbolic.svg';
        this.ICON_UNTRASH_BUTTON = extensionIconsPath + '/'+'trash-full-symbolic.svg';
        
        // take a variety of actions when the gsettings values are modified by prefs
        this._settings = this._extension.getSettings();

        // create Soup session
        this._initSoup();

        this.visible = !this._settings.get_boolean('hide');

        this.refreshDueItem = newMenuItem(_("<No refresh scheduled>"));
        this.explainItem = newMenuItem(_("Awaiting refresh..."));
        this.copyrightItem = newMenuItem(_("Awaiting refresh..."));
        this.folderItem = newMenuItem(_("Open image folder"));
        this.dwallpaperItem = newMenuItem(_("Set background image"));
        this.swallpaperItem = newMenuItem(_("Set lock screen image"));
        this.refreshItem = newMenuItem(_("Refresh Now"));
        this.settingsItem = newMenuItem(_("Settings"));
        this.openImageItem = newMenuItem(_("Open in image viewer"));
        this.openImageInfoLinkItem = newMenuItem(_("Open Bing image information page"));
        this.imageResolutionItem = newMenuItem(_("Awaiting refresh..."));

        this.titleItem = new PopupMenu.PopupSubMenuMenuItem(_("Awaiting refresh..."), false);
        [this.imageResolutionItem, this.openImageInfoLinkItem, this.openImageItem, this.folderItem,
            this.dwallpaperItem]
                .forEach(e => this.titleItem.menu.addMenuItem(e));

        // quick settings submenu
        this.settingsSubMenu = new PopupMenu.PopupSubMenuMenuItem(_("Quick settings"), false);
        // toggles under the quick settings submenu
        this.toggleSetBackground = newMenuSwitchItem(_("Set background image"), this._settings.get_boolean('set-background'));
        this.toggleSelectNew = newMenuSwitchItem(_("Always show new images"), this._settings.get_boolean('revert-to-current-image'));
        this.toggleShuffle = newMenuSwitchItem(_("Image shuffle mode"), true);
        this.toggleShuffleOnlyFaves = newMenuSwitchItem(_("Image shuffle only favorites"), this._settings.get_boolean('random-mode-include-only-favourites'));
        /*this.toggleNotifications = newMenuSwitchItem(_("Enable desktop notifications"), this._settings.get_boolean('notify'));*/
        this.toggleImageCount = newMenuSwitchItem(_("Show image count"), this._settings.get_boolean('show-count-in-image-title'));
        this.toggleShuffleOnlyUHD = newMenuSwitchItem(_("Image shuffle only UHD resolutions"), this._settings.get_boolean('random-mode-include-only-uhd'));
        
        [/*this.toggleNotifications, this.toggleImageCount, this.toggleSetBackground,*/ this.toggleSelectNew, 
            this.toggleShuffle, this.toggleShuffleOnlyFaves, this.toggleShuffleOnlyUHD]
                .forEach(e => this.settingsSubMenu.menu.addMenuItem(e));

        // these items are a bit unique, we'll populate them in _setControls()
        this.controlItem = newMenuItem("");
        this.thumbnailItem = new PopupMenu.PopupBaseMenuItem({ style_class: 'wp-thumbnail-image'});       
        this._setControls(); // build the button bar

        // we need to word-wrap these menu items to not overflow menu in case of long lines of text
        [this.refreshDueItem, this.titleItem, this.explainItem, this.copyrightItem]
            .forEach((e, i) => {
                this._wrapLabelItem(e);
            });
        
        // set the order of menu items (including separators)
        let allMenuItems = [ 
            this.refreshItem, 
            this.refreshDueItem, 
            new PopupMenu.PopupSeparatorMenuItem(),
            this.controlItem,
            new PopupMenu.PopupSeparatorMenuItem(),
            this.explainItem, 
            this.thumbnailItem, 
            this.titleItem, 
            this.copyrightItem,
            new PopupMenu.PopupSeparatorMenuItem(),
            this.settingsSubMenu,
            this.settingsItem
        ];
        allMenuItems.forEach(e => this.menu.addMenuItem(e));

        // non clickable information items
        [this.explainItem, this.copyrightItem, this.refreshDueItem, this.thumbnailItem, this.imageResolutionItem]
            .forEach((e) => {
                e.setSensitive(false);
            });
        
        if (this._settings.get_boolean('random-mode-enabled')) {
            [this.toggleShuffleOnlyFaves, this.toggleShuffleOnlyUHD]
                .forEach((e) => {
                    e.setSensitive(false);
                });
        }
        
        this._setConnections();
        
        if (this._settings.get_string('state') != '[]') { // setting state on reset or initial boot
            this._reStoreState();
        }
        else {
            this._restartTimeout(60); // wait 60 seconds before performing refresh
        }
    }

    // create Soup session
    _initSoup() {
        this.httpSession = new Soup.Session();
        this.httpSession.user_agent = 'User-Agent: Mozilla/5.0 (X11; GNOME Shell/' + Config.PACKAGE_VERSION + '; Linux x86_64; +https://github.com/neffo/bing-wallpaper-gnome-extension ) BingWallpaper Gnome Extension/' + this._extension.metadata.version;
    }

    // listen for configuration changes
    _setConnections() {
        this.settings_connections = [];

        this.settings_connections.push(
            this._settings.connect('changed::hide', () => {
                this.visible = !this._settings.get_boolean('hide');
            })
        );
        
        let settingConnections = [
            {signal: 'changed::icon-name', call: this._setIcon},
            {signal: 'changed::market', call: this._refresh},
            {signal: 'changed::set-background', call: this._setBackground},
            {signal: 'changed::selected-image', call: this._setImage},
            {signal: 'changed::delete-previous', call: this._cleanUpImages},
            {signal: 'changed::notify', call: this._notifyCurrentImage},
            {signal: 'changed::always-export-bing-json', call: this._exportData},
            {signal: 'changed::bing-json', call: this._exportData},
            {signal: 'changed::controls-icon-size', call: this._setControls},
            {signal: 'changed::random-mode-enabled', call: this._randomModeChanged},
            {signal: 'changed::random-mode-include-only-favourites', call: this._randomModeChanged},
            {signal: 'changed::random-mode-include-only-unhidden', call: this._randomModeChanged},
            {signal: 'changed::random-mode-include-only-uhd', call: this._randomModeChanged},
            {signal: 'changed::random-interval-mode', call: this._randomModeChanged}
        ];

        // _setShuffleToggleState
        settingConnections.forEach((e) => {
            this.settings_connections.push(
                this._settings.connect(e.signal, e.call.bind(this))
            );
        });
        
        // ensure we're in a sensible initial state
        this._setIcon();
        this._setImage();
        this._cleanUpImages();

        // menu connections 
        this.connect('button-press-event', this._openMenu.bind(this));

        // link menu items to functions
        //this.thumbnailItem.connect('activate', this._setBackgroundDesktop.bind(this));
        this.thumbnailItem.connect('activate', this._openInSystemViewer.bind(this));
        this.openImageItem.connect('activate', this._openInSystemViewer.bind(this));
        //this.titleItem.connect('activate', this._setBackgroundDesktop.bind(this));
        this.openImageInfoLinkItem.connect('activate', this._openImageInfoLink.bind(this)); 
        this.dwallpaperItem.connect('activate', this._setBackgroundDesktop.bind(this));
        this.refreshItem.connect('activate', this._refresh.bind(this));
        this.settingsItem.connect('activate', this._openPrefs.bind(this));
        
        // unfortunately we can't bind toggles trivially like we can with prefs.js here, so we handle toggles in two steps
        // first, we listen for changes to these toggle settings and update the status
        // & then, link settings to toggle state (the other way) 

        let toggles = [ /*{key: 'set-background', toggle: this.toggleSetBackground},*/
                        {key: 'revert-to-current-image', toggle: this.toggleSelectNew},
                        /*{key: 'notify', toggle: this.toggleNotifications},
                        {key: 'show-count-in-image-title', toggle: this.toggleImageCount},*/
                        {key: 'random-mode-enabled', toggle: this.toggleShuffle},
                        {key: 'random-mode-include-only-favourites', toggle: this.toggleShuffleOnlyFaves},
                        /*{key: 'random-mode-include-only-unhidden', toggle: this.toggleShuffleOnlyUnhidden},*/
                        {key: 'random-mode-include-only-uhd', toggle: this.toggleShuffleOnlyUHD}];
        
        toggles.forEach( (e) => {
            this.settings_connections.push(
                this._settings.connect('changed::'+e.key, () => {
                    BingLog(e.key+' setting changed to '+ (this._settings.get_boolean(e.key)?'true':'false'));
                    e.toggle.setToggleState(this._settings.get_boolean(e.key));
                })
            );
            e.toggle.connect('toggled', (item, state) => {
                BingLog(e.key+' switch toggled to '+ (state?'true':'false'));
                this._setBooleanSetting(e.key, state);
            });
        });

        this.folderItem.connect('activate', Utils.openImageFolder.bind(this, this._settings));
    }

    _setBooleanSetting(key, state) {
        let success = this._settings.set_boolean(key, state);
        BingLog('key '+key+' set to ' + (state?'true':'false') + ' (returned ' + (success?'true':'false')+')');
    }

    _setStringSetting(key, value) {
        let success = this._settings.set_string(key, value);
        BingLog('key '+key+' set to ' + value + ' (returned ' + (success?'true':'false')+')');
    }

    _setIntSetting(key, value) {
        let success = this._settings.set_int(key, value);
        BingLog('key '+key+' set to ' + value + ' (returned ' + (success?'true':'false')+')');
    }

    _onDestroy() {
        this._unsetConnections();
    }

    _unsetConnections() {
        this.settings_connections.forEach((e) => {
            this._settings.disconnect(e);
        });
    }

    _openPrefs() {
        this._extension.openPreferences();
    }

    _openMenu() {
        // Grey out menu items if an update is pending
        this.refreshItem.setSensitive(!this._updatePending);
        this.thumbnailItem.setSensitive(!this._updatePending && this.imageURL != "");
        this.dwallpaperItem.setSensitive(!this._updatePending && this.filename != "");
        this.swallpaperItem.setSensitive(!this._updatePending && this.filename != "");
        this.titleItem.setSensitive(!this._updatePending && this.imageinfolink != "");
        let maxlongdate = Utils.getMaxLongDate(this._settings);
        this.refreshduetext = 
            _("Next refresh") + ": " + (this.refreshdue ? this.refreshdue.format("%Y-%m-%d %X") : '-') + 
            " (" + (this.refreshdue?Utils.friendly_time_diff(this.refreshdue):"-") + ")\n" + 
            _("Last refresh") + ": " + (maxlongdate? this._localeDate(maxlongdate, true) : '-');
        // also show when shuffle is next due
        if (this._settings.get_boolean('random-mode-enabled')) {
            this.refreshduetext += "\n" + _("Next shuffle")+": " +
                (this.shuffledue ? this.shuffledue.format("%Y-%m-%d %X") : '-') +
                " (" + (this.refreshdue?Utils.friendly_time_diff(this.shuffledue):"-") + ")";
        }
        BingLog('refreshduetext :'+this.refreshduetext);
        this.refreshDueItem.label.set_text(this.refreshduetext);            
    }

    _setImage() {
        Utils.validate_imagename(this._settings);
        this.selected_image = this._settings.get_string('selected-image');
        BingLog('selected image changed to: ' + this.selected_image);
        this._selectImage();
        //this._setShuffleToggleState();
    }

    _notifyCurrentImage() {
        if (this._settings.get_boolean('notify')) {
            let image = this._getCurrentImage();
            if (image) {
                this._createImageNotification(image);
            }
        }
    }

    // set indicator icon (tray icon)
    _setIcon() {
        Utils.validate_icon(this._settings, this._extension.path);
        let icon_name = this._settings.get_string('icon-name');
        let gicon = Gio.icon_new_for_string(this._extension.dir.get_child('icons').get_path() + '/' + icon_name + '.svg');
        this.icon = new St.Icon({gicon: gicon, style_class: 'system-status-icon'});
        BingLog('Replace icon set to: ' + icon_name);
        this.remove_all_children();
        this.add_child(this.icon);
    }

    // set backgrounds as requested and set preview image in menu
    _setBackground() {
        if (this.filename == '')
            return;
        this.thumbnail = new Thumbnail(this.filename, St.ThemeContext.get_for_stage(global.stage).scale_factor); // use scale factor to make them look nicer
        this._setThumbnailImage();
        if (!this.dimensions.width || !this.dimensions.height) // if dimensions aren't in image database yet
            [this.dimensions.width, this.dimensions.height] = Utils.getFileDimensions(this.filename);
        BingLog('image set to : '+this.filename);
        if (this._settings.get_boolean('set-background'))
            this._setBackgroundDesktop();
    }

    _setBackgroundDesktop() {
        doSetBackground(this.filename, Utils.DESKTOP_SCHEMA);
    }

    // set a timer on when the current image is going to expire
    _restartTimeoutFromLongDate(longdate) {
        // all Bing times are in UTC (+0)
        let refreshDue = Utils.dateFromLongDate(longdate, 86400).to_local();
        let now = GLib.DateTime.new_now_local();
        let difference = refreshDue.difference(now) / 1000000;
             
        if (difference < 60 || difference > 86400) // clamp to a reasonable range
            difference = 60;
        difference = difference + 300; // 5 minute fudge offset in case of inaccurate local clock
        
        BingLog('Next refresh due ' + difference + ' seconds from now');
        this._restartTimeout(difference);
    }

    // alternative shuffle mode, not yet enabled
    _restartShuffleTimeoutFromDueDate(duedate) {
        let now = GLib.DateTime.new_now_local();
        let difference = duedate.difference(now) / 1000000;
        if (difference < 60 || difference > 86400) // clamp to a reasonable range
            difference = 60;

        BingLog('Next shuffle due ' + difference + ' seconds from now');
        this._restartShuffleTimeout(difference);
    }

    // convert longdate format into human friendly format
    _localeDate(longdate, include_time = false) {
        try {
            let date = Utils.dateFromLongDate(longdate, 300); // date at update
            return date.to_local().format('%Y-%m-%d' + (include_time? ' %X' : '')); // ISO 8601 - https://xkcd.com/1179/
        }
        catch (e) {
            return 'none';
        }
    }

    // set menu text in lieu of a notification/popup
    _setMenuText() {
        this.titleItem.label.set_text(this.title ? this.title : '');  
        this.copyrightItem.label.set_text(this.copyright ? this.copyright : '');
        this.imageResolutionItem.label.set_text(this.dimensions.width+'px x '+this.dimensions.height+'px');
        if (this._settings.get_boolean('show-count-in-image-title') && this.explanation) {
            let imageList = JSON.parse(this._settings.get_string('bing-json'));
            if (imageList.length > 0)
                this.explainItem.label.set_text( this.explanation + ' [' + (this.imageIndex + 1) + '/' + imageList.length + ']');
        }
        else {
            this.explainItem.label.set_text(this.explanation ? this.explanation : '');
        }
        this._setFavouriteIcon(this.favourite_status?this.ICON_FAVE_BUTTON:this.ICON_UNFAVE_BUTTON);
        this._setTrashIcon(this.hidden_status?this.ICON_UNTRASH_BUTTON:this.ICON_TRASH_BUTTON);
    }

    _wrapLabelItem(menuItem) {
        let clutter_text = menuItem.label.get_clutter_text();
        clutter_text.set_line_wrap(true);
        clutter_text.set_ellipsize(0);
        clutter_text.set_max_length(0);
        menuItem.label.set_style('max-width: 420px;');
    }

    _setControls() {
        this.favouriteBtn = this._newMenuIcon(
            this.favourite_status?this.ICON_FAVE_BUTTON:this.ICON_UNFAVE_BUTTON,
            this.controlItem, 
            this._favouriteImage);
        this.trashBtn = this._newMenuIcon(
            this.hidden_status?this.ICON_UNTRASH_BUTTON:this.ICON_TRASH_BUTTON,
            this.controlItem, 
            this._trashImage);
        this.prevBtn = this._newMenuIcon(
            ICON_PREVIOUS_BUTTON, 
            this.controlItem, 
            this._prevImage);
        this.nextBtn = this._newMenuIcon(
            ICON_NEXT_BUTTON, 
            this.controlItem, 
            this._nextImage);
        this.curBtn = this._newMenuIcon(
            ICON_CURRENT_BUTTON, 
            this.controlItem, 
            this._curImage);
        this.randomizeBtn = this._newMenuIcon(
            this.ICON_RANDOM,
            this.controlItem, 
            this._selectImage,
            null, true);
    }

    _newMenuIcon(icon_name, parent, fn, position = null, arg = null) {
        let gicon = Gio.icon_new_for_string(icon_name);
        let icon = new St.Icon({
            /*icon_name: icon_name,*/
            gicon: gicon,
            style_class: 'popup-menu-icon',
            x_expand: true,
            y_expand: true,
            icon_size: this._settings.get_int('controls-icon-size')
        });

        let iconBtn = new St.Button({
            style_class: 'ci-action-btn',
            can_focus: true,
            child: icon,
            /* x_align: Clutter.ActorAlign.END, // FIXME: errors on GNOME 3.28, default to center is ok */
            x_expand: true,
            y_expand: true
        });

        if (position !== null) {
            parent.insert_child_at_index(iconBtn, position);
        }
        else {
            parent.add_child(iconBtn);
        }
            
        iconBtn.connect('button-press-event', fn.bind(this, arg));
        return iconBtn;
    }

    // set menu thumbnail
    _setThumbnailImage() {
        let pixbuf = this.thumbnail.pixbuf;
        let scale_factor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
        
        if (pixbuf == null)
            return;

        const {width, height} = pixbuf;
        
        if (height == 0) {
            return;
        }

        const [version] = Config.PACKAGE_VERSION.split('.').map(s => Number(s));
        const image = new St.ImageContent();
        const success = image.set_data.apply(image, [
            ...version >= 48 ? [Clutter.get_default_backend().get_cogl_context()] : [],
            pixbuf.get_pixels(),
            pixbuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 : Cogl.PixelFormat.RGB_888,
            width,
            height,
            pixbuf.get_rowstride(),
        ]);

        if (!success) {
            throw Error("error creating St.ImageContent()");
        }

        this.thumbnailItem.hexpand = false;
        this.thumbnailItem.vexpand = false;
        this.thumbnailItem.content = image;
        
        BingLog('scale factor: ' + scale_factor);
        this.thumbnailItem.set_size(480*scale_factor, 270*scale_factor);
        this.thumbnailItem.setSensitive(true);
    }

    _nextImage() {
        this._gotoImage(1);
    }

    _prevImage() {
        this._gotoImage(-1);
    }

    _curImage() {
        this._setStringSetting('selected-image', 'current');
        this._gotoImage(0);
    }

    _randomModeChanged() {
        let randomEnabled = this._settings.get_boolean('random-mode-enabled');
        Utils.validate_interval(this._settings);
        [this.toggleShuffleOnlyFaves, this.toggleShuffleOnlyUHD /*, this.toggleShuffleOnlyUnhidden*/]
            .forEach( x => {
                x.setSensitive(randomEnabled);
            });
        if (randomEnabled) {
            BingLog('enabled shuffle mode, by setting a shuffe timer (5 seconds)');
            this._restartShuffleTimeout(5);
            this._setBooleanSetting('revert-to-current-image', false);
        }
        else {
            // clear shuffle timer
            if (this._shuffleTimeout)
                GLib.source_remove(this._shuffleTimeout);
            this._setBooleanSetting('revert-to-current-image', true);
        }
    }

    _favouriteImage() {
        BingLog('favourite image '+this.imageURL+' status was '+this.favourite_status);
        this.favourite_status = !this.favourite_status;
        Utils.setImageFavouriteStatus(this._settings, this.imageURL, this.favourite_status);
        this._setFavouriteIcon(this.favourite_status?this.ICON_FAVE_BUTTON:this.ICON_UNFAVE_BUTTON);
    }

    _trashImage() {
        BingLog('trash image '+this.imageURL+' status was '+this.hidden_status);
        this.hidden_status = !this.hidden_status;
        Utils.setImageHiddenStatus(this._settings, this.imageURL, this.hidden_status);
        this._setTrashIcon(this.hidden_status?this.ICON_UNTRASH_BUTTON:this.ICON_TRASH_BUTTON);
        if (this._settings.get_boolean('trash-deletes-images')) {
            BingLog('image to be deleted: '+this.filename);
            Utils.deleteImage(this.filename);
            Utils.validate_imagename(this._settings);
        }
        
    }

    _setFavouriteIcon(icon_name) {
        let gicon = Gio.icon_new_for_string(icon_name);
        this.favouriteBtn.get_children().forEach( (x, i) => {
            x.set_gicon(gicon);
        });
    }

    _setTrashIcon(icon_name) {
        let gicon = Gio.icon_new_for_string(icon_name);
        this.trashBtn.get_children().forEach( (x, i) => {
            x.set_gicon(gicon);
        });
    }

    _gotoImage(relativePos) {
        let imageList = Utils.getImageList(this._settings);
        let curIndex = 0;
        
        if (this.selected_image == 'current') {
            curIndex = Utils.getCurrentImageIndex(imageList);
        }
        else {
            curIndex = Utils.imageIndex(imageList, this.selected_image);
        }
        
        let newImage = Utils.getImageByIndex(imageList, curIndex + relativePos);
        
        if (newImage)
            this._setStringSetting('selected-image', newImage.urlbase.replace('/th?id=OHR.', ''));
    }

    _getCurrentImage() {
        let imageList = Utils.getImageList(this._settings);
        let curIndex = Utils.getCurrentImageIndex(imageList);
        
        return Utils.getImageByIndex(imageList, curIndex);
    }

    // download Bing metadata
    async _refresh() {
        if (this._updatePending)
            return;
        this._updatePending = true;
        this._restartTimeout();
        
        let market = this._settings.get_string('market');
        // Soup3 should be the version used, but in the past some distros have packaged older versions only
        if (Soup.MAJOR_VERSION >= 3) {
            let url = BingImageURL;
            let params = Utils.BingParams;
            params['mkt'] = ( market != 'auto' ? market : '' );
            
            // if we've set previous days to be something less than 8 and 
            // delete previous is active we want to just request a subset of wallpapers
            if (this._settings.get_boolean('delete-previous') == true && this._settings.get_int('previous-days')<8) {
                params['n'] = ""+this._settings.get_int('previous-days');
            }

            let request = Soup.Message.new_from_encoded_form('GET', url, Soup.form_encode_hash(params));
            request.request_headers.append('Accept', 'application/json');

            try {
                await this.httpSession.send_and_read_async(request, GLib.PRIORITY_DEFAULT, null, (httpSession, message) => {
                    this._processMessageRefresh(message);
                });
            }
            catch(error) {
                BingLog('unable to send libsoup json message '+error);
                notifyError('Unable to fetch Bing metadata\n'+error);
            }
        }
        else {
            let url = BingImageURL + '?format=js&idx=0&n=8&mbl=1&mkt=' + (market != 'auto' ? market : '');
            let request = Soup.Message.new('GET', url);
            request.request_headers.append('Accept', 'application/json');

            // queue the http request
            try {
                this.httpSession.queue_message(request, (httpSession, message) => {
                    this._processMessageRefresh(message);
                });
            }
            catch (error) {
                BingLog('unable to send libsoup json message '+error);
                notifyError('Unable to fetch Bing metadata\n'+error);
            }
        }
    }

    _processMessageRefresh(message) {
        const decoder = new TextDecoder();
        try {
            let data = (Soup.MAJOR_VERSION >= 3) ? 
                decoder.decode(this.httpSession.send_and_read_finish(message).get_data()): // Soup3
                message.response_body.data; // Soup 2
            
            BingLog('Recieved ' + data.length + ' bytes');
            this._parseData(data);
            
            if (!this._settings.get_boolean('random-mode-enabled'))
                this._selectImage();
        }
        catch (error) {
            BingLog('Network error occured: ' + error);
            notifyError('network error occured\n'+error);
            this._updatePending = false;
            this._restartTimeout(TIMEOUT_SECONDS_ON_HTTP_ERROR);
        }
    }

    // sets a timer for next refresh of Bing metadata
    _restartTimeout(seconds = null) {
        if (this._timeout)
            GLib.source_remove(this._timeout);

        if (seconds == null)
            seconds = TIMEOUT_SECONDS;
        
        this._timeout = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, this._refresh.bind(this));
        this.refreshdue = GLib.DateTime.new_now_local().add_seconds(seconds);
        BingLog('next check in ' + seconds + ' seconds');
    }

    _restartShuffleTimeout(seconds = null) {
        BingLog('_restartShuffleTimeout('+seconds+')');
        //console.trace();

        if (this._shuffleTimeout)
            GLib.source_remove(this._shuffleTimeout);

        if (seconds == null) {
            let diff = -Math.floor(GLib.DateTime.new_now_local().difference(this.shuffledue)/1000000);
            BingLog('shuffle ('+this.shuffledue.format_iso8601()+') diff = '+diff);
            if (diff > 30) { // on occasions the above will be 1 second
                seconds = diff; // if not specified, we should maintain the existing shuffle timeout (i.e. we just restored from saved state)
            }
            else if (this._settings.get_string('random-interval-mode') != 'custom') {
                let random_mode = this._settings.get_string('random-interval-mode');
                seconds = Utils.seconds_until(random_mode); // else we shuffle at specified interval (midnight default)
                BingLog('shuffle mode = '+random_mode+' = '+seconds+' from now');
            }
            else {
                seconds = this._settings.get_int('random-interval'); // or whatever the user has specified (as a timer)
            }
        }

        this._shuffleTimeout = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, this._selectImage.bind(this, true));
        this.shuffledue = GLib.DateTime.new_now_local().add_seconds(seconds);
        BingLog('next shuffle in ' + seconds + ' seconds');
    }

    // auto export Bing data to JSON file if requested
    _exportData() {
        if (this._settings.get_boolean('always-export-bing-json')) { // save copy of current JSON
            Utils.exportBingJSON(this._settings);
        }
    }

    // process Bing metadata
    _parseData(data) {
        try {
            let parsed = JSON.parse(data);
            let datamarket = parsed.market.mkt;
            let prefmarket = this._settings.get_string('market');
            let newImages = Utils.mergeImageLists(this._settings, parsed.images);
            
            if (datamarket != prefmarket && prefmarket != 'auto')
                BingLog('WARNING: Bing returning market data for ' + datamarket + ' rather than selected ' + prefmarket);
            
            Utils.purgeImages(this._settings); // delete older images if enabled
            //Utils.cleanupImageList(this._settings); // merged into purgeImages
            this._downloadAllImages(); // fetch missing images that are still available
            Utils.populateImageListResolutions(this._settings);
            
            if (newImages.length > 0 && this._settings.get_boolean('revert-to-current-image')) {
                // user wants to switch to the new image when it arrives
                this._setStringSetting('selected-image', 'current');
            }

            if (this._settings.get_boolean('notify')) {
                if (!this._settings.get_boolean('notify-only-latest')) {
                    // notify all new images
                    newImages.forEach((image) => {
                            BingLog('New image to notify: ' + Utils.getImageTitle(image));
                            this._createImageNotification(image);
                    });
                }
                else {
                    // notify only the most recent image
                    let last = newImages.pop();
                    if (last) {
                        BingLog('New image to notify: ' + Utils.getImageTitle(last));
                        this._createImageNotification(last);
                    }
                }
            }

            this._restartTimeoutFromLongDate(parsed.images[0].fullstartdate); // timing is set by Bing, and possibly varies by market
            this._updatePending = false;
        }
        catch (error) {
            BingLog('_parseData() failed with error ' + error + ' @ '+error.lineNumber);
            notifyError('Bing metadata parsing error check ' + error + ' @ '+error.lineNumber);
            BingLog(error.stack);
        }
    }

    _cleanUpImages() {
        if (this._settings.get_boolean('delete-previous')) {
            Utils.purgeImages(this._settings);
        }
    }

    _createImageNotification(image) {
        let msg = _('Bing Wallpaper of the Day for') + ' ' + this._localeDate(image.fullstartdate);
        let details = Utils.getImageTitle(image);
        this._createNotification(msg, details);
        BingLog('_createImageNotification: '+msg+' details: '+details);
    }

    _createNotification(msg, details) {
        const systemSource = MessageTray.getSystemSource();
        const bingNotify = new MessageTray.Notification({
            source: systemSource,
            title: msg,
            body: details,
            gicon: new Gio.ThemedIcon({name: 'image-x-generic'}),
            iconName: 'image-x-generic',
        });
        systemSource.addNotification(bingNotify);
        //Main.notify(msg, details);
        BingLog('_createNotification: '+msg+' details: '+details);
    }

    _shuffleImage() {
        let image = null;
        let imageList = Utils.getImageList(this._settings);
        let filter = { 'faves': this._settings.get_boolean('random-mode-include-only-favourites'),
                        'hidden': this._settings.get_boolean('random-mode-include-only-unhidden'),
                        'min_height': this._settings.get_boolean('random-mode-include-only-uhd')?this._settings.get_int('min-uhd-height'):false
                    };
        let favImageList = Utils.getImageList(this._settings, filter);

        if (favImageList.length >= MINIMUM_SHUFFLE_IMAGES) { // we have the minimum images to shuffle, if not fall back to shuffle all iamges
            imageList = favImageList;
        }
        else {
            BingLog('not enough filtered images available to shuffle');
        }

        // shuffle could fail for a number of reasons
        try {
            this.imageIndex = Utils.getRandomInt(imageList.length);
            image = imageList[this.imageIndex];

            BingLog('shuffled to image '+image.urlbase);

            return image;
        }
        catch (e) {
            BingLog('shuffle failed '+e);
            return null;
        }
    }

    _selectImage(force_shuffle = false) {
        let imageList = Utils.getImageList(this._settings);
        let image = null;
        // special values, 'current' is most recent (default mode), 'random' picks one at random, anything else should be filename
        
        if (force_shuffle) {
            BingLog('forcing shuffle of image')
            image = this._shuffleImage();
            if (this._settings.get_boolean('random-mode-enabled'))
                this._restartShuffleTimeout();
        }

        if (!image) {
            if (this.selected_image == 'current') {
                image = Utils.getCurrentImage(imageList);
                this.imageIndex = Utils.getCurrentImageIndex(imageList);
            } else {
                image = Utils.inImageList(imageList, this.selected_image);

                if (!image) // if we didn't find it, try for current
                    image = Utils.getCurrentImage(imageList);

                if (image)
                    this.imageIndex = Utils.imageIndex(imageList, image.urlbase);
                BingLog('_selectImage: ' + this.selected_image + ' = ' + (image && image.urlbase ? image.urlbase : 'not found'));
            }
        }

        if (!image)
            return; // could force, image = imageList[0] or perhaps force refresh

        if (image.url != '') {
            let resolution = Utils.getResolution(this._settings, image);
            let BingWallpaperDir = Utils.getWallpaperDir(this._settings);

            // set current image details at extension scope
            this.title = image.copyright.replace(/\s*[\(\（].*?[\)\）]\s*/g, '');
            this.explanation = _('Bing Wallpaper of the Day for') + ' ' + this._localeDate(image.startdate);
            this.copyright = image.copyright.match(/[\(\（]([^)]+)[\)\）]/)[1].replace('\*\*', ''); // Japan locale uses （） rather than ()
            this.longstartdate = image.fullstartdate;
            this.imageinfolink = image.copyrightlink.replace(/^http:\/\//i, 'https://');
            this.imageURL = BingURL + image.urlbase + '_' + resolution + '.jpg'+'&qlt=100'; // generate image url for user's resolution @ high quality
            this.filename = Utils.toFilename(BingWallpaperDir, image.startdate, image.urlbase, resolution);
            this.dimensions.width = image.width?image.width:null;
            this.dimensions.height = image.height?image.height:null;
            this.selected_image = Utils.getImageUrlBase(image);
            this._setStringSetting('selected-image', this.selected_image);

            if (("favourite" in image) && image.favourite === true ) {
                this.favourite_status = true;
            }
            else {
                this.favourite_status = false;
            }

            if (("hidden" in image) && image.hidden === true ) {
                this.hidden_status = true;
            }
            else {
                this.hidden_status = false;
            }
            
            let file = Gio.file_new_for_path(this.filename);
            let file_exists = file.query_exists(null);
            let file_info = file_exists ? file.query_info ('*', Gio.FileQueryInfoFlags.NONE, null) : 0;

            if (!file_exists || file_info.get_size () == 0) { // file doesn't exist or is empty (probably due to a network error)
                this._downloadImage(this.imageURL, file, true);
            }
            else {
                this._setBackground();
                this._updatePending = false;
            }
        } 
        else {
            this.title = _("No wallpaper available");
            this.explanation = _("No picture for today.");
            this.filename = "";
            this._updatePending = false;
        }

        this._setMenuText();
        this._storeState();
    }

    _imageURL(urlbase, resolution) {
        return BingURL + urlbase + '_' + resolution + '.jpg';
    }

    _storeState() {
        if (this.filename) {
            let maxLongDate = Utils.getMaxLongDate(this._settings); // refresh date from most recent Bing image
            let state = {maxlongdate: maxLongDate, title: this.title, explanation: this.explanation, copyright: this.copyright,
                longstartdate: this.longstartdate, imageinfolink: this.imageinfolink, imageURL: this.imageURL,
                filename: this.filename, favourite: this.favourite_status, width: this.dimensions.width, 
                height: this.dimensions.height, 
                shuffledue: (this.shuffledue.to_unix? this.shuffledue.to_unix():0)
            };
            let stateJSON = JSON.stringify(state);
            
            BingLog('Storing state as JSON: ' + stateJSON);
            this._setStringSetting('state', stateJSON);
        }
    }

    _reStoreState() {
        try {
            // patch for relative paths, ensures that users running git version don't end up with broken state - see EGO review for version 38 https://extensions.gnome.org/review/30299
            this._setStringSetting('download-folder', this._settings.get_string('download-folder').replace('$HOME', '~'));
            let stateJSON = this._settings.get_string('state');
            let state = JSON.parse(stateJSON);
            let maxLongDate = null;
            
            BingLog('restoring state...');
            maxLongDate = state.maxlongdate ? state.maxlongdate : null;
            this.title = state.title;
            this.explanation = state.explanation;
            this.copyright = state.copyright;
            this.longstartdate = state.longstartdate;
            this.imageinfolink = state.imageinfolink;
            this.imageURL = state.imageURL;
            this.filename = state.filename;
            this.dimensions.width = state.width;
            this.dimensions.height = state.height;
            this._selected_image = this._settings.get_string('selected-image');
            this.shuffledue = ("shuffledue" in state)? GLib.DateTime.new_from_unix_local(state.shuffledue) : 0;
            this.favourite_status = ("favourite" in state && state.favourite === true);
            // update menus and thumbnail
            this._setMenuText();
            this._setBackground();
            
            if (!maxLongDate) {
                this._restartTimeout(60);
                return;
            } 
            
            if (this._settings.get_boolean('random-mode-enabled')) {
                BingLog('random mode enabled, restarting random state');
                this._restartShuffleTimeoutFromDueDate(this.shuffledue); // FIXME: use state value
                this._restartTimeoutFromLongDate(maxLongDate);
            }
            else {
                this._restartTimeoutFromLongDate(maxLongDate);
            }

            return;
        }
        catch (error) {
            BingLog('bad state - refreshing... error was ' + error);
        }
        this._restartTimeout(60);
    }

    _downloadAllImages() {
        // fetch recent undownloaded images       
        let imageList = Utils.getFetchableImageList(this._settings);
        let BingWallpaperDir = Utils.getWallpaperDir(this._settings);
        imageList.forEach( (image) => {
            let resolution = Utils.getResolution(this._settings, image);
            let filename = Utils.toFilename(BingWallpaperDir, image.startdate, image.urlbase, resolution);
            let url = this._imageURL(image.urlbase, resolution);
            let file = Gio.file_new_for_path(filename);
            this._downloadImage(url, file, false);
        });
    }

    // download and process new image
    // FIXME: improve error handling
    async _downloadImage(url, file, set_background) {
        let BingWallpaperDir = Utils.getWallpaperDir(this._settings);
        let dir = Gio.file_new_for_path(BingWallpaperDir);
        if (!dir.query_exists(null)) {
            //dir.make_directory_with_parents(null);
            notifyError('Download folder '+BingWallpaperDir+' does not exist or is not writable');
            return;
        }
        BingLog("Downloading " + url + " to " + file.get_uri());
        let request = Soup.Message.new('GET', url);

        // queue the http request
        try {
            if (Soup.MAJOR_VERSION >= 3) {
                await this.httpSession.send_and_read_async(request, GLib.PRIORITY_DEFAULT, null, (httpSession, message) => {
                    this._processFileDownload(message, file, set_background);
                });
            }
            else {
                this.httpSession.queue_message(request, (httpSession, message) => {
                    this._processFileDownload(message, file, set_background);
                });
            }
        }
        catch (error) {
            BingLog('error sending libsoup message '+error);
            notifyError('Network error '+error);
        }
    }

    async _processFileDownload(message, file, set_background) {            
        try {
            let data = (Soup.MAJOR_VERSION >= 3) ? 
                this.httpSession.send_and_read_finish(message).get_data():
                message.response_body.flatten().get_as_bytes();

            const [etag] = await file.replace_contents_bytes_async(
                data,
                null,
                false,
                Gio.FileCreateFlags.REPLACE_DESTINATION,
                null,
                (file, res) => {
                    try {
                        file.replace_contents_finish(res);
                        if (set_background)
                            this._setBackground();
                        BingLog('Download successful');
                    } 
                    catch(e) {
                        BingLog('Error writing file: ' + e);
                        notifyError('Image '+file.get_path()+' is not writable, check folder permissions or select a different folder\n'+e);
                    }
                }
            );
        }
        catch (error) {
            BingLog('Unable download image '+error);
            notifyError('Image '+file.get_path()+' file error, check folder permissions, disk space or select a different folder\n'+e);
        }
    }

    // open image in default image view
    _openInSystemViewer() {
        Utils.openInSystemViewer(this.filename);
    }

    // open Bing image information page
    _openImageInfoLink() {
        if (this.imageinfolink)
            Utils.openInSystemViewer(this.imageinfolink, false);
    }

    stop() {
        if (this._timeout)
            GLib.source_remove(this._timeout);

        if (this._shuffleTimeout)
            GLib.source_remove(this._shuffleTimeout);

        this._timeout = undefined;
        this._shuffleTimeout = undefined;
        this.menu.removeAll();
    }
});

export default class BingWallpaperExtension extends Extension {
    enable() {
        bingWallpaperIndicator = new BingWallpaperIndicator(this);
        Main.panel.addToStatusArea(IndicatorName, bingWallpaperIndicator);
    }
    disable() {
        bingWallpaperIndicator.stop();
        bingWallpaperIndicator.destroy();
        bingWallpaperIndicator = null;
    }
}

function toFilename(wallpaperDir, startdate, imageURL, resolution) {
    return wallpaperDir + startdate + '-' + imageURL.replace(/^.*[\\\/]/, '').replace('th?id=OHR.', '') + '_' + resolution + '.jpg';
}

