// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-

import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GnomeDesktop from 'gi://GnomeDesktop';
import GObject from 'gi://GObject';
import GWeather from 'gi://GWeather';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';

import * as Main from './main.js';
import * as PanelMenu from './panelMenu.js';
import * as Calendar from './calendar.js';
import * as Weather from '../misc/weather.js';

import {formatDateWithCFormatString, formatTime, clearCachedLocalTimeZone} from '../misc/dateUtils.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';

const NC_ = (context, str) => `${context}\u0004${str}`;
const T_ = Shell.util_translate_time_string;

const MAX_FORECASTS = 5;
const EN_CHAR = '\u2013';

const ClocksIntegrationIface = loadInterfaceXML('org.gnome.Shell.ClocksIntegration');
const ClocksProxy = Gio.DBusProxy.makeProxyWrapper(ClocksIntegrationIface);

/**
 * @private
 *
 * @param {Date} date a Date
 * @returns {boolean}
 */
function _isToday(date) {
    let now = new Date();
    return now.getFullYear() === date.getFullYear() &&
           now.getMonth() === date.getMonth() &&
           now.getDate() === date.getDate();
}

/**
 * @private
 *
 * @param {GLib.DateTime} datetime a GLib.DateTime
 * @returns {Date}
 */
function _gDateTimeToDate(datetime) {
    return new Date(datetime.to_unix() * 1000 + datetime.get_microsecond() / 1000);
}

const TodayButton = GObject.registerClass(
class TodayButton extends St.Button {
    _init(calendar) {
        // Having the ability to go to the current date if the user is already
        // on the current date can be confusing. So don't make the button reactive
        // until the selected date changes.
        super._init({
            style_class: 'datemenu-today-button',
            x_expand: true,
            can_focus: true,
            reactive: false,
        });

        const hbox = new St.BoxLayout({
            vertical: true,
            x_expand: true,
        });
        this.child = hbox;

        this._dayLabel = new St.Label({
            style_class: 'day-label',
            x_align: Clutter.ActorAlign.START,
        });
        hbox.add_child(this._dayLabel);

        this._dateLabel = new St.Label({style_class: 'date-label'});
        hbox.add_child(this._dateLabel);

        this._calendar = calendar;
        this._calendar.connect('selected-date-changed', (_calendar, datetime) => {
            // Make the button reactive only if the selected date is not the
            // current date.
            this.reactive = !_isToday(_gDateTimeToDate(datetime));
        });
    }

    vfunc_clicked() {
        this._calendar.setDate(new Date(), false);
    }

    setDate(date) {
        this._dayLabel.set_text(formatDateWithCFormatString(date, '%A'));

        /* Translators: This is the date format to use when the calendar popup is
         * shown - it is shown just below the time in the top bar (e.g.,
         * "Tue 9:29 AM").  The string itself should become a full date, e.g.,
         * "February 17 2015".
         */
        const dateFormat = Shell.util_translate_time_string(N_('%B %-d %Y'));
        this._dateLabel.set_text(formatDateWithCFormatString(date, dateFormat));

        /* Translators: This is the accessible name of the date button shown
         * below the time in the shell; it should combine the weekday and the
         * date, e.g. "Tuesday February 17 2015".
         */
        const dateAccessibleNameFormat = Shell.util_translate_time_string(N_('%A %B %e %Y'));
        this.accessible_name = formatDateWithCFormatString(date, dateAccessibleNameFormat);
    }
});

const EventsSection = GObject.registerClass(
class EventsSection extends St.Button {
    _init() {
        super._init({
            style_class: 'events-button',
            can_focus: true,
            x_expand: true,
            child: new St.BoxLayout({
                style_class: 'events-box',
                vertical: true,
                x_expand: true,
            }),
        });

        this._startDate = null;
        this._endDate = null;

        this._eventSource = null;
        this._calendarApp = null;

        this._title = new St.Label({
            style_class: 'events-title',
        });
        this.child.add_child(this._title);

        this._eventsList = new St.BoxLayout({
            style_class: 'events-list',
            vertical: true,
            x_expand: true,
        });
        this.child.add_child(this._eventsList);

        this._appSys = Shell.AppSystem.get_default();
        this._appSys.connect('installed-changed',
            this._appInstalledChanged.bind(this));
        this._appInstalledChanged();
    }

    setDate(date) {
        this._startDate =
            new Date(date.getFullYear(), date.getMonth(), date.getDate());
        this._endDate =
            new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);

        this._updateTitle();
        this._reloadEvents();
    }

    setEventSource(eventSource) {
        if (!(eventSource instanceof Calendar.EventSourceBase))
            throw new Error('Event source is not valid type');

        this._eventSource = eventSource;
        this._eventSource.connect('changed', this._reloadEvents.bind(this));
        this._eventSource.connect('notify::has-calendars',
            this._sync.bind(this));
        this._sync();
    }

    _updateTitle() {
        /* Translators: Shown on calendar heading when selected day occurs on current year */
        const sameYearFormat = T_(NC_('calendar heading', '%B %-d'));

        /* Translators: Shown on calendar heading when selected day occurs on different year */
        const otherYearFormat = T_(NC_('calendar heading', '%B %-d %Y'));

        const timeSpanDay = GLib.TIME_SPAN_DAY / 1000;
        const now = new Date();

        if (this._startDate <= now && now < this._endDate)
            this._title.text = _('Today');
        else if (this._endDate <= now && now - this._endDate < timeSpanDay)
            this._title.text = _('Yesterday');
        else if (this._startDate > now && this._startDate - now <= timeSpanDay)
            this._title.text = _('Tomorrow');
        else if (this._startDate.getFullYear() === now.getFullYear())
            this._title.text = formatDateWithCFormatString(this._startDate, sameYearFormat);
        else
            this._title.text = formatDateWithCFormatString(this._startDate, otherYearFormat);
    }

    _isAtMidnight(eventTime) {
        return eventTime.getHours() === 0 && eventTime.getMinutes() === 0 && eventTime.getSeconds() === 0;
    }

    _formatEventTime(event) {
        const eventStart = event.date;
        let eventEnd = event.end;

        const allDay =
            eventStart.getTime() === this._startDate.getTime() && eventEnd.getTime() === this._endDate.getTime();

        const startsBeforeToday = eventStart < this._startDate;
        const endsAfterToday = eventEnd > this._endDate;

        const startTimeOnly = formatTime(eventStart, {timeOnly: true});
        const endTimeOnly = formatTime(eventEnd, {timeOnly: true});

        const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;

        let title;
        if (allDay) {
            /* Translators: Shown in calendar event list for all day events
             * Keep it short, best if you can use less then 10 characters
             */
            title = C_('event list time', 'All Day');
        } else if (startsBeforeToday || endsAfterToday) {
            const now = new Date();
            const thisYear = now.getFullYear();

            const startsAtMidnight = this._isAtMidnight(eventStart);
            const endsAtMidnight = this._isAtMidnight(eventEnd);

            const startYear = eventStart.getFullYear();

            if (endsAtMidnight) {
                eventEnd = new Date(eventEnd);
                eventEnd.setDate(eventEnd.getDate() - 1);
            }

            const endYear = eventEnd.getFullYear();

            let format;
            if (startYear === thisYear && thisYear === endYear)
                /* Translators: Shown in calendar event list as the start/end of events
                 * that only show day and month
                 */
                format = T_(N_('%m/%d'));
            else
                format = '%x';

            const startDateOnly = formatDateWithCFormatString(eventStart, format);
            const endDateOnly = formatDateWithCFormatString(eventEnd, format);

            if (startsAtMidnight && endsAtMidnight)
                title = `${rtl ? endDateOnly : startDateOnly} ${EN_CHAR} ${rtl ? startDateOnly : endDateOnly}`;
            else if (rtl)
                title = `${endTimeOnly} ${endDateOnly} ${EN_CHAR} ${startTimeOnly} ${startDateOnly}`;
            else
                title = `${startDateOnly} ${startTimeOnly} ${EN_CHAR} ${endDateOnly} ${endTimeOnly}`;
        } else if (eventStart === eventEnd) {
            title = startTimeOnly;
        } else {
            title = `${rtl ? endTimeOnly : startTimeOnly} ${EN_CHAR} ${rtl ? startTimeOnly : endTimeOnly}`;
        }

        return title;
    }

    _reloadEvents() {
        if (this._eventSource.isLoading || this._reloading)
            return;

        this._reloading = true;

        [...this._eventsList].forEach(c => c.destroy());

        const events =
            this._eventSource.getEvents(this._startDate, this._endDate);

        for (let event of events) {
            const box = new St.BoxLayout({
                style_class: 'event-box',
                vertical: true,
            });
            box.add_child(new St.Label({
                text: event.summary,
                style_class: 'event-summary',
            }));
            box.add_child(new St.Label({
                text: this._formatEventTime(event),
                style_class: 'event-time',
            }));
            this._eventsList.add_child(box);
        }

        if (this._eventsList.get_n_children() === 0) {
            const placeholder = new St.Label({
                text: _('No Events'),
                style_class: 'event-placeholder',
            });
            this._eventsList.add_child(placeholder);
        }

        this._reloading = false;
        this._sync();
    }

    vfunc_clicked() {
        Main.overview.hide();
        Main.panel.closeCalendar();

        const appInfo = this._calendarApp;
        const context = global.create_app_launch_context(0, -1);
        if (appInfo.get_id() === 'org.gnome.Evolution.desktop')
            appInfo.launch_action('calendar', context);
        else
            appInfo.launch([], context);
    }

    _appInstalledChanged() {
        const apps = Gio.AppInfo.get_recommended_for_type('text/calendar');
        if (apps && (apps.length > 0)) {
            const app = Gio.AppInfo.get_default_for_type('text/calendar', false);
            const defaultInRecommended = apps.some(a => a.equal(app));
            this._calendarApp = defaultInRecommended ? app : apps[0];
        } else {
            this._calendarApp = null;
        }

        return this._sync();
    }

    _sync() {
        this.visible = this._eventSource && this._eventSource.hasCalendars;
        this.reactive = this._calendarApp !== null;
    }
});

const WorldClocksSection = GObject.registerClass(
class WorldClocksSection extends St.Button {
    _init() {
        super._init({
            style_class: 'world-clocks-button',
            can_focus: true,
            x_expand: true,
        });
        this._clock = new GnomeDesktop.WallClock();
        this._clockNotifyId = 0;
        this._tzNotifyId = 0;

        this._locations = [];

        const layout = new Clutter.GridLayout({orientation: Clutter.Orientation.VERTICAL});
        this._grid = new St.Widget({
            style_class: 'world-clocks-grid',
            x_expand: true,
            layout_manager: layout,
        });
        layout.hookup_style(this._grid);

        this.child = this._grid;

        this._clocksApp = null;
        this._clocksProxy = new ClocksProxy(
            Gio.DBus.session,
            'org.gnome.clocks',
            '/org/gnome/clocks',
            this._onProxyReady.bind(this),
            null /* cancellable */,
            Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES);

        this._settings = new Gio.Settings({
            schema_id: 'org.gnome.shell.world-clocks',
        });
        this._settings.connect('changed', this._clocksChanged.bind(this));
        this._clocksChanged();

        this._appSystem = Shell.AppSystem.get_default();
        this._appSystem.connect('installed-changed',
            this._sync.bind(this));
        this._sync();
    }

    vfunc_clicked() {
        if (this._clocksApp)
            this._clocksApp.activate();

        Main.overview.hide();
        Main.panel.closeCalendar();
    }

    _sync() {
        this._clocksApp = this._appSystem.lookup_app('org.gnome.clocks.desktop');
        this.visible = this._clocksApp != null;
    }

    _clocksChanged() {
        this._grid.destroy_all_children();
        this._locations = [];

        let world = GWeather.Location.get_world();
        let clocks = this._settings.get_value('locations').deepUnpack();
        for (let i = 0; i < clocks.length; i++) {
            let l = world.deserialize(clocks[i]);
            if (l && l.get_timezone() != null)
                this._locations.push({location: l});
        }

        const unixtime = GLib.DateTime.new_now_local().to_unix();
        this._locations.sort((a, b) => {
            const tzA = a.location.get_timezone();
            const tzB = b.location.get_timezone();
            const intA = tzA.find_interval(GLib.TimeType.STANDARD, unixtime);
            const intB = tzB.find_interval(GLib.TimeType.STANDARD, unixtime);
            return tzA.get_offset(intA) - tzB.get_offset(intB);
        });

        let layout = this._grid.layout_manager;
        const title = this._locations.length === 0
            ? _('Add world clocks…')
            : _('World Clocks');
        const header = new St.Label({
            style_class: 'world-clocks-header',
            x_align: Clutter.ActorAlign.START,
            text: title,
        });
        if (this._grid.text_direction === Clutter.TextDirection.RTL)
            layout.attach(header, 2, 0, 1, 1);
        else
            layout.attach(header, 0, 0, 2, 1);
        this.label_actor = header;

        for (let i = 0; i < this._locations.length; i++) {
            let l = this._locations[i].location;

            let name = l.get_city_name() || l.get_name();
            const label = new St.Label({
                style_class: 'world-clocks-city',
                text: name,
                x_align: Clutter.ActorAlign.START,
                y_align: Clutter.ActorAlign.CENTER,
                x_expand: true,
            });

            const time = new St.Label({style_class: 'world-clocks-time'});

            const tz = new St.Label({
                style_class: 'world-clocks-timezone',
                x_align: Clutter.ActorAlign.END,
                y_align: Clutter.ActorAlign.CENTER,
            });

            time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
            tz.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;

            if (this._grid.text_direction === Clutter.TextDirection.RTL) {
                layout.attach(tz, 0, i + 1, 1, 1);
                layout.attach(time, 1, i + 1, 1, 1);
                layout.attach(label, 2, i + 1, 1, 1);
            } else {
                layout.attach(label, 0, i + 1, 1, 1);
                layout.attach(time, 1, i + 1, 1, 1);
                layout.attach(tz, 2, i + 1, 1, 1);
            }

            this._locations[i].timeLabel = time;
            this._locations[i].tzLabel = tz;
        }

        if (this._grid.get_n_children() > 1) {
            if (!this._clockNotifyId) {
                this._clockNotifyId =
                    this._clock.connect('notify::clock', this._updateTimeLabels.bind(this));
            }
            if (!this._tzNotifyId) {
                this._tzNotifyId =
                    this._clock.connect('notify::timezone', this._updateTimezoneLabels.bind(this));
            }
            this._updateTimeLabels();
            this._updateTimezoneLabels();
        } else {
            if (this._clockNotifyId)
                this._clock.disconnect(this._clockNotifyId);
            this._clockNotifyId = 0;

            if (this._tzNotifyId)
                this._clock.disconnect(this._tzNotifyId);
            this._tzNotifyId = 0;
        }
    }

    _getTimezoneOffsetAtLocation(location) {
        const tz = location.get_timezone();
        const localOffset = GLib.DateTime.new_now_local().get_utc_offset();
        const utcOffset = GLib.DateTime.new_now(tz).get_utc_offset();
        const offsetCurrentTz = utcOffset - localOffset;
        const offsetHours =
            Math.floor(Math.abs(offsetCurrentTz) / GLib.TIME_SPAN_HOUR);
        const offsetMinutes =
            (Math.abs(offsetCurrentTz) % GLib.TIME_SPAN_HOUR) /
            GLib.TIME_SPAN_MINUTE;

        const prefix = offsetCurrentTz >= 0 ? '+' : '-';
        const text = offsetMinutes === 0
            ? `${prefix}${offsetHours}`
            : `${prefix}${offsetHours}\u2236${offsetMinutes}`;
        return text;
    }

    _updateTimeLabels() {
        for (let i = 0; i < this._locations.length; i++) {
            let l = this._locations[i];
            const now = GLib.DateTime.new_now(l.location.get_timezone());
            l.timeLabel.text = formatTime(now, {timeOnly: true});
        }
    }

    _updateTimezoneLabels() {
        for (let i = 0; i < this._locations.length; i++) {
            let l = this._locations[i];
            l.tzLabel.text = this._getTimezoneOffsetAtLocation(l.location);
        }
    }

    _onProxyReady(proxy, error) {
        if (error) {
            log(`Failed to create GNOME Clocks proxy: ${error}`);
            return;
        }

        this._clocksProxy.connect('g-properties-changed',
            this._onClocksPropertiesChanged.bind(this));
        this._onClocksPropertiesChanged();
    }

    _onClocksPropertiesChanged() {
        if (this._clocksProxy.g_name_owner == null)
            return;

        this._settings.set_value('locations',
            new GLib.Variant('av', this._clocksProxy.Locations));
    }
});

const WeatherSection = GObject.registerClass(
class WeatherSection extends St.Button {
    _init() {
        super._init({
            style_class: 'weather-button',
            can_focus: true,
            x_expand: true,
        });

        this._weatherClient = new Weather.WeatherClient();

        let box = new St.BoxLayout({
            style_class: 'weather-box',
            vertical: true,
            x_expand: true,
        });

        this.child = box;

        let titleBox = new St.BoxLayout({style_class: 'weather-header-box'});
        this._titleLabel = new St.Label({
            style_class: 'weather-header',
            x_align: Clutter.ActorAlign.START,
            x_expand: true,
            y_align: Clutter.ActorAlign.END,
        });
        titleBox.add_child(this._titleLabel);
        box.add_child(titleBox);

        this._titleLocation = new St.Label({
            style_class: 'weather-header location',
            x_align: Clutter.ActorAlign.END,
            y_align: Clutter.ActorAlign.END,
        });
        titleBox.add_child(this._titleLocation);

        let layout = new Clutter.GridLayout({orientation: Clutter.Orientation.VERTICAL});
        this._forecastGrid = new St.Widget({
            style_class: 'weather-grid',
            layout_manager: layout,
        });
        layout.hookup_style(this._forecastGrid);
        box.add_child(this._forecastGrid);

        this._weatherClient.connect('changed', this._sync.bind(this));
        this._sync();
    }

    vfunc_map() {
        this._weatherClient.update();
        super.vfunc_map();
    }

    vfunc_clicked() {
        this._weatherClient.activateApp();

        Main.overview.hide();
        Main.panel.closeCalendar();
    }

    _getInfos() {
        let forecasts = this._weatherClient.info.get_forecast_list();

        let now = GLib.DateTime.new_now_local();
        let current = GLib.DateTime.new_from_unix_local(0);
        let infos = [];
        for (let i = 0; i < forecasts.length; i++) {
            const [valid, timestamp] = forecasts[i].get_value_update();
            if (!valid || timestamp === 0)
                continue;  // 0 means 'never updated'

            const datetime = GLib.DateTime.new_from_unix_local(timestamp);
            if (now.difference(datetime) > 0)
                continue; // Ignore earlier forecasts

            if (datetime.difference(current) < GLib.TIME_SPAN_HOUR)
                continue; // Enforce a minimum interval of 1h

            if (infos.push(forecasts[i]) === MAX_FORECASTS)
                break; // Use a maximum of five forecasts

            current = datetime;
        }
        return infos;
    }

    _addForecasts() {
        let layout = this._forecastGrid.layout_manager;

        let infos = this._getInfos();
        if (this._forecastGrid.text_direction === Clutter.TextDirection.RTL)
            infos.reverse();

        let col = 0;
        infos.forEach(fc => {
            const [valid_, timestamp] = fc.get_value_update();
            let timeStr = formatTime(new Date(timestamp * 1000), {
                timeOnly: true,
                ampm: false,
            });
            const [, tempValue] = fc.get_value_temp(GWeather.TemperatureUnit.DEFAULT);
            const tempPrefix = Math.round(tempValue) >= 0 ? ' ' : '';

            let time = new St.Label({
                style_class: 'weather-forecast-time',
                text: timeStr,
                x_align: Clutter.ActorAlign.CENTER,
            });
            let icon = new St.Icon({
                style_class: 'weather-forecast-icon',
                icon_name: fc.get_symbolic_icon_name(),
                x_align: Clutter.ActorAlign.CENTER,
                x_expand: true,
            });
            let temp = new St.Label({
                style_class: 'weather-forecast-temp',
                text: `${tempPrefix}${Math.round(tempValue)}°`,
                x_align: Clutter.ActorAlign.CENTER,
            });

            temp.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
            time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;

            layout.attach(time, col, 0, 1, 1);
            layout.attach(icon, col, 1, 1, 1);
            layout.attach(temp, col, 2, 1, 1);
            col++;
        });
    }

    _setStatusLabel(text) {
        let layout = this._forecastGrid.layout_manager;
        let label = new St.Label({text});
        layout.attach(label, 0, 0, 1, 1);
    }

    _findBestLocationName(loc) {
        const locName = loc.get_name();

        if (loc.get_level() === GWeather.LocationLevel.CITY ||
            !loc.has_coords())
            return locName;

        const world = GWeather.Location.get_world();
        const city = world.find_nearest_city(...loc.get_coords());
        const cityName = city.get_name();

        return locName.includes(cityName) ? cityName : locName;
    }

    _updateForecasts() {
        this._forecastGrid.destroy_all_children();

        if (!this._weatherClient.hasLocation)
            return;

        const {info} = this._weatherClient;
        this._titleLocation.text = this._findBestLocationName(info.location);

        if (this._weatherClient.loading) {
            this._setStatusLabel(_('Loading…'));
            return;
        }

        if (info.is_valid()) {
            this._addForecasts();
            return;
        }

        if (info.network_error())
            this._setStatusLabel(_('Go online for weather information'));
        else
            this._setStatusLabel(_('Weather information is currently unavailable'));
    }

    _sync() {
        this.visible = this._weatherClient.available;

        if (!this.visible)
            return;

        if (this._weatherClient.hasLocation)
            this._titleLabel.text = _('Weather');
        else
            this._titleLabel.text = _('Select weather location…');

        this._forecastGrid.visible = this._weatherClient.hasLocation;
        this._titleLocation.visible = this._weatherClient.hasLocation;

        this._updateForecasts();
    }
});

const MessagesIndicator = GObject.registerClass(
class MessagesIndicator extends St.Icon {
    _init() {
        super._init({
            style_class: 'messages-indicator',
            visible: false,
            y_expand: true,
            y_align: Clutter.ActorAlign.CENTER,
        });

        this._sources = [];
        this._count = 0;

        this._settings = new Gio.Settings({
            schema_id: 'org.gnome.desktop.notifications',
        });
        this._settings.connect('changed::show-banners', this._sync.bind(this));

        Main.messageTray.connect('source-added', this._onSourceAdded.bind(this));
        Main.messageTray.connect('source-removed', this._onSourceRemoved.bind(this));
        Main.messageTray.connect('queue-changed', this._updateCount.bind(this));

        let sources = Main.messageTray.getSources();
        sources.forEach(source => this._onSourceAdded(null, source));

        this._sync();

        this.connect('destroy', () => {
            this._settings.run_dispose();
            this._settings = null;
        });
    }

    _onSourceAdded(tray, source) {
        source.connect('notify::count', this._updateCount.bind(this));
        this._sources.push(source);
        this._updateCount();
    }

    _onSourceRemoved(tray, source) {
        this._sources.splice(this._sources.indexOf(source), 1);
        this._updateCount();
    }

    _updateCount() {
        let count = 0;
        this._sources.forEach(source => (count += source.unseenCount));
        this._count = count - Main.messageTray.queueCount;

        this._sync();
    }

    _sync() {
        let doNotDisturb = !this._settings.get_boolean('show-banners');
        this.icon_name = doNotDisturb
            ? 'notifications-disabled-symbolic'
            : 'message-indicator-symbolic';
        this.visible = doNotDisturb || this._count > 0;
    }
});

const FreezableBinLayout = GObject.registerClass(
class FreezableBinLayout extends Clutter.BinLayout {
    _init() {
        super._init();

        this._frozen = false;
        this._savedWidth = [NaN, NaN];
        this._savedHeight = [NaN, NaN];
    }

    set frozen(v) {
        if (this._frozen === v)
            return;

        this._frozen = v;
        if (!this._frozen)
            this.layout_changed();
    }

    vfunc_get_preferred_width(container, forHeight) {
        if (!this._frozen || this._savedWidth.some(isNaN))
            return super.vfunc_get_preferred_width(container, forHeight);
        return this._savedWidth;
    }

    vfunc_get_preferred_height(container, forWidth) {
        if (!this._frozen || this._savedHeight.some(isNaN))
            return super.vfunc_get_preferred_height(container, forWidth);
        return this._savedHeight;
    }

    vfunc_allocate(container, allocation) {
        super.vfunc_allocate(container, allocation);

        let [width, height] = allocation.get_size();
        this._savedWidth = [width, width];
        this._savedHeight = [height, height];
    }
});

const CalendarColumnLayout = GObject.registerClass(
class CalendarColumnLayout extends Clutter.BoxLayout {
    _init(actors) {
        super._init({orientation: Clutter.Orientation.VERTICAL});
        this._colActors = actors;
    }

    vfunc_get_preferred_width(container, forHeight) {
        const actors =
            this._colActors.filter(a => a.get_parent() === container);
        if (actors.length === 0)
            return super.vfunc_get_preferred_width(container, forHeight);
        return actors.reduce(([minAcc, natAcc], child) => {
            const [min, nat] = child.get_preferred_width(forHeight);
            return [Math.max(minAcc, min), Math.max(natAcc, nat)];
        }, [0, 0]);
    }
});

export const DateMenuButton = GObject.registerClass(
class DateMenuButton extends PanelMenu.Button {
    _init() {
        let hbox;

        super._init(0.5);

        this._clockDisplay = new St.Label({style_class: 'clock'});
        this._clockDisplay.clutter_text.y_align = Clutter.ActorAlign.CENTER;
        this._clockDisplay.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;

        this._indicator = new MessagesIndicator();

        const indicatorPad = new St.Widget();
        this._indicator.bind_property('visible',
            indicatorPad, 'visible',
            GObject.BindingFlags.SYNC_CREATE);
        indicatorPad.add_constraint(new Clutter.BindConstraint({
            source: this._indicator,
            coordinate: Clutter.BindCoordinate.SIZE,
        }));

        let box = new St.BoxLayout({style_class: 'clock-display-box'});
        box.add_child(indicatorPad);
        box.add_child(this._clockDisplay);
        box.add_child(this._indicator);

        this.label_actor = this._clockDisplay;
        this.add_child(box);
        this.add_style_class_name('clock-display');

        let layout = new FreezableBinLayout();
        let bin = new St.Widget({layout_manager: layout});
        // For some minimal compatibility with PopupMenuItem
        bin._delegate = this;
        this.menu.box.add_child(bin);

        hbox = new St.BoxLayout({name: 'calendarArea'});
        bin.add_child(hbox);

        this._calendar = new Calendar.Calendar();
        this._calendar.connect('selected-date-changed', (_calendar, datetime) => {
            let date = _gDateTimeToDate(datetime);
            layout.frozen = !_isToday(date);
            this._eventsItem.setDate(date);
        });
        this._date = new TodayButton(this._calendar);

        this.menu.connect('open-state-changed', (menu, isOpen) => {
            // Whenever the menu is opened, select today
            if (isOpen) {
                let now = new Date();
                this._calendar.setDate(now);
                this._date.setDate(now);
                this._eventsItem.setDate(now);
            }
        });

        // Fill up the first column
        this._messageList = new Calendar.CalendarMessageList();
        hbox.add_child(this._messageList);

        // Fill up the second column
        const boxLayout = new CalendarColumnLayout([this._calendar, this._date]);
        const vbox = new St.Widget({
            style_class: 'datemenu-calendar-column',
            layout_manager: boxLayout,
        });
        boxLayout.hookup_style(vbox);
        hbox.add_child(vbox);

        vbox.add_child(this._date);
        vbox.add_child(this._calendar);

        const displaysBox = new St.BoxLayout({
            vertical: true,
            x_expand: true,
            style_class: 'datemenu-displays-box',
        });

        this._displaysSection = new St.ScrollView({
            style_class: 'datemenu-displays-section vfade',
            x_expand: true,
            overlay_scrollbars: true,
            vscrollbar_policy: St.PolicyType.EXTERNAL,
            child: displaysBox,
        });
        vbox.add_child(this._displaysSection);

        this._eventsItem = new EventsSection();
        displaysBox.add_child(this._eventsItem);

        this._clocksItem = new WorldClocksSection();
        displaysBox.add_child(this._clocksItem);

        this._weatherItem = new WeatherSection();
        displaysBox.add_child(this._weatherItem);

        // Done with hbox for calendar and event list

        this._clock = new GnomeDesktop.WallClock();
        this._clock.bind_property('clock', this._clockDisplay, 'text', GObject.BindingFlags.SYNC_CREATE);
        this._clock.connect('notify::timezone', this._updateTimeZone.bind(this));

        Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
        this._sessionUpdated();
    }

    _getEventSource() {
        return new Calendar.DBusEventSource();
    }

    _setEventSource(eventSource) {
        if (this._eventSource)
            this._eventSource.destroy();

        this._calendar.setEventSource(eventSource);
        this._eventsItem.setEventSource(eventSource);

        this._eventSource = eventSource;
    }

    _updateTimeZone() {
        clearCachedLocalTimeZone();

        this._calendar.updateTimeZone();
    }

    _sessionUpdated() {
        let eventSource;
        let showEvents = Main.sessionMode.showCalendarEvents;
        if (showEvents)
            eventSource = this._getEventSource();
        else
            eventSource = new Calendar.EmptyEventSource();

        this._setEventSource(eventSource);

        // Displays are not actually expected to launch Settings when activated
        // but the corresponding app (clocks, weather); however we can consider
        // that display-specific settings, so re-use "allowSettings" here ...
        this._displaysSection.visible = Main.sessionMode.allowSettings;
    }
});
