/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.deskclock.data;

import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.StringRes;
import android.view.View;

import com.android.deskclock.Predicate;
import com.android.deskclock.R;
import com.android.deskclock.Utils;
import com.android.deskclock.timer.TimerService;

import java.util.Calendar;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;

import static android.content.Context.AUDIO_SERVICE;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
import static android.media.AudioManager.FLAG_SHOW_UI;
import static android.media.AudioManager.STREAM_ALARM;
import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS;
import static android.provider.Settings.ACTION_SOUND_SETTINGS;
import static com.android.deskclock.Utils.enforceMainLooper;
import static com.android.deskclock.Utils.enforceNotMainLooper;

/**
 * All application-wide data is accessible through this singleton.
 */
public final class DataModel {

    /** Indicates the display style of clocks. */
    public enum ClockStyle {ANALOG, DIGITAL}

    /** Indicates the preferred sort order of cities. */
    public enum CitySort {NAME, UTC_OFFSET}

    /** Indicates the preferred behavior of hardware volume buttons when firing alarms. */
    public enum AlarmVolumeButtonBehavior {NOTHING, SNOOZE, DISMISS}

    /** Indicates the reason alarms may not fire or may fire silently. */
    public enum SilentSetting {
        @SuppressWarnings("unchecked")
        DO_NOT_DISTURB(R.string.alarms_blocked_by_dnd, 0, Predicate.FALSE, null),
        @SuppressWarnings("unchecked")
        MUTED_VOLUME(R.string.alarm_volume_muted,
                R.string.unmute_alarm_volume,
                Predicate.TRUE,
                new UnmuteAlarmVolumeListener()),
        SILENT_RINGTONE(R.string.silent_default_alarm_ringtone,
                R.string.change_setting_action,
                new ChangeSoundActionPredicate(),
                new ChangeSoundSettingsListener()),
        @SuppressWarnings("unchecked")
        BLOCKED_NOTIFICATIONS(R.string.app_notifications_blocked,
                R.string.change_setting_action,
                Predicate.TRUE,
                new ChangeAppNotificationSettingsListener());

        private final @StringRes int mLabelResId;
        private final @StringRes int mActionResId;
        private final Predicate<Context> mActionEnabled;
        private final View.OnClickListener mActionListener;

        SilentSetting(int labelResId, int actionResId, Predicate<Context> actionEnabled,
                View.OnClickListener actionListener) {
            mLabelResId = labelResId;
            mActionResId = actionResId;
            mActionEnabled = actionEnabled;
            mActionListener = actionListener;
        }

        public @StringRes int getLabelResId() { return mLabelResId; }
        public @StringRes int getActionResId() { return mActionResId; }
        public View.OnClickListener getActionListener() { return mActionListener; }
        public boolean isActionEnabled(Context context) {
            return mLabelResId != 0 && mActionEnabled.apply(context);
        }

        private static class UnmuteAlarmVolumeListener implements View.OnClickListener {
            @Override
            public void onClick(View v) {
                // Set the alarm volume to 11/16th of max and show the slider UI.
                // 11/16th of max is the initial volume of the alarm stream on a fresh install.
                final Context context = v.getContext();
                final AudioManager am = (AudioManager) context.getSystemService(AUDIO_SERVICE);
                final int index = Math.round(am.getStreamMaxVolume(STREAM_ALARM) * 11f / 16f);
                am.setStreamVolume(STREAM_ALARM, index, FLAG_SHOW_UI);
            }
        }

        private static class ChangeSoundSettingsListener implements View.OnClickListener {
            @Override
            public void onClick(View v) {
                final Context context = v.getContext();
                context.startActivity(new Intent(ACTION_SOUND_SETTINGS)
                        .addFlags(FLAG_ACTIVITY_NEW_TASK));
            }
        }

        private static class ChangeSoundActionPredicate implements Predicate<Context> {
            @Override
            public boolean apply(Context context) {
                final Intent intent = new Intent(ACTION_SOUND_SETTINGS);
                return intent.resolveActivity(context.getPackageManager()) != null;
            }
        }

        private static class ChangeAppNotificationSettingsListener implements View.OnClickListener {
            @Override
            public void onClick(View v) {
                final Context context = v.getContext();
                if (Utils.isLOrLater()) {
                    try {
                        // Attempt to open the notification settings for this app.
                        context.startActivity(
                                new Intent("android.settings.APP_NOTIFICATION_SETTINGS")
                                .putExtra("app_package", context.getPackageName())
                                .putExtra("app_uid", context.getApplicationInfo().uid)
                                .addFlags(FLAG_ACTIVITY_NEW_TASK));
                        return;
                    } catch (Exception ignored) {
                        // best attempt only; recovery code below
                    }
                }

                // Fall back to opening the app settings page.
                context.startActivity(new Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
                        .setData(Uri.fromParts("package", context.getPackageName(), null))
                        .addFlags(FLAG_ACTIVITY_NEW_TASK));
            }
        }
    }

    public static final String ACTION_WORLD_CITIES_CHANGED =
            "com.android.deskclock.WORLD_CITIES_CHANGED";

    /** The single instance of this data model that exists for the life of the application. */
    private static final DataModel sDataModel = new DataModel();

    private Handler mHandler;

    private Context mContext;

    /** The model from which settings are fetched. */
    private SettingsModel mSettingsModel;

    /** The model from which city data are fetched. */
    private CityModel mCityModel;

    /** The model from which timer data are fetched. */
    private TimerModel mTimerModel;

    /** The model from which alarm data are fetched. */
    private AlarmModel mAlarmModel;

    /** The model from which widget data are fetched. */
    private WidgetModel mWidgetModel;

    /** The model from which data about settings that silence alarms are fetched. */
    private SilentSettingsModel mSilentSettingsModel;

    /** The model from which stopwatch data are fetched. */
    private StopwatchModel mStopwatchModel;

    /** The model from which notification data are fetched. */
    private NotificationModel mNotificationModel;

    /** The model from which time data are fetched. */
    private TimeModel mTimeModel;

    /** The model from which ringtone data are fetched. */
    private RingtoneModel mRingtoneModel;

    public static DataModel getDataModel() {
        return sDataModel;
    }

    private DataModel() {}

    /**
     * Initializes the data model with the context and shared preferences to be used.
     */
    public void init(Context context, SharedPreferences prefs) {
        if (mContext != context) {
            mContext = context.getApplicationContext();

            mTimeModel = new TimeModel(mContext);
            mWidgetModel = new WidgetModel(prefs);
            mNotificationModel = new NotificationModel();
            mRingtoneModel = new RingtoneModel(mContext, prefs);
            mSettingsModel = new SettingsModel(mContext, prefs, mTimeModel);
            mCityModel = new CityModel(mContext, prefs, mSettingsModel);
            mAlarmModel = new AlarmModel(mContext, mSettingsModel);
            mSilentSettingsModel = new SilentSettingsModel(mContext, mNotificationModel);
            mStopwatchModel = new StopwatchModel(mContext, prefs, mNotificationModel);
            mTimerModel = new TimerModel(mContext, prefs, mSettingsModel, mRingtoneModel,
                    mNotificationModel);
        }
    }

    /**
     * Convenience for {@code run(runnable, 0)}, i.e. waits indefinitely.
     */
    public void run(Runnable runnable) {
        try {
            run(runnable, 0 /* waitMillis */);
        } catch (InterruptedException ignored) {
        }
    }

    /**
     * Updates all timers and the stopwatch after the device has shutdown and restarted.
     */
    public void updateAfterReboot() {
        enforceMainLooper();
        mTimerModel.updateTimersAfterReboot();
        mStopwatchModel.setStopwatch(getStopwatch().updateAfterReboot());
    }

    /**
     * Updates all timers and the stopwatch after the device's time has changed.
     */
    public void updateAfterTimeSet() {
        enforceMainLooper();
        mTimerModel.updateTimersAfterTimeSet();
        mStopwatchModel.setStopwatch(getStopwatch().updateAfterTimeSet());
    }

    /**
     * Posts a runnable to the main thread and blocks until the runnable executes. Used to access
     * the data model from the main thread.
     */
    public void run(Runnable runnable, long waitMillis) throws InterruptedException {
        if (Looper.myLooper() == Looper.getMainLooper()) {
            runnable.run();
            return;
        }

        final ExecutedRunnable er = new ExecutedRunnable(runnable);
        getHandler().post(er);

        // Wait for the data to arrive, if it has not.
        synchronized (er) {
            if (!er.isExecuted()) {
                er.wait(waitMillis);
            }
        }
    }

    /**
     * @return a handler associated with the main thread
     */
    private synchronized Handler getHandler() {
        if (mHandler == null) {
            mHandler = new Handler(Looper.getMainLooper());
        }
        return mHandler;
    }

    //
    // Application
    //

    /**
     * @param inForeground {@code true} to indicate the application is open in the foreground
     */
    public void setApplicationInForeground(boolean inForeground) {
        enforceMainLooper();

        if (mNotificationModel.isApplicationInForeground() != inForeground) {
            mNotificationModel.setApplicationInForeground(inForeground);

            // Refresh all notifications in response to a change in app open state.
            mTimerModel.updateNotification();
            mTimerModel.updateMissedNotification();
            mStopwatchModel.updateNotification();
            mSilentSettingsModel.updateSilentState();
        }
    }

    /**
     * @return {@code true} when the application is open in the foreground; {@code false} otherwise
     */
    public boolean isApplicationInForeground() {
        enforceMainLooper();
        return mNotificationModel.isApplicationInForeground();
    }

    /**
     * Called when the notifications may be stale or absent from the notification manager and must
     * be rebuilt. e.g. after upgrading the application
     */
    public void updateAllNotifications() {
        enforceMainLooper();
        mTimerModel.updateNotification();
        mTimerModel.updateMissedNotification();
        mStopwatchModel.updateNotification();
    }

    //
    // Cities
    //

    /**
     * @return a list of all cities in their display order
     */
    public List<City> getAllCities() {
        enforceMainLooper();
        return mCityModel.getAllCities();
    }

    /**
     * @return a city representing the user's home timezone
     */
    public City getHomeCity() {
        enforceMainLooper();
        return mCityModel.getHomeCity();
    }

    /**
     * @return a list of cities not selected for display
     */
    public List<City> getUnselectedCities() {
        enforceMainLooper();
        return mCityModel.getUnselectedCities();
    }

    /**
     * @return a list of cities selected for display
     */
    public List<City> getSelectedCities() {
        enforceMainLooper();
        return mCityModel.getSelectedCities();
    }

    /**
     * @param cities the new collection of cities selected for display by the user
     */
    public void setSelectedCities(Collection<City> cities) {
        enforceMainLooper();
        mCityModel.setSelectedCities(cities);
    }

    /**
     * @return a comparator used to locate index positions
     */
    public Comparator<City> getCityIndexComparator() {
        enforceMainLooper();
        return mCityModel.getCityIndexComparator();
    }

    /**
     * @return the order in which cities are sorted
     */
    public CitySort getCitySort() {
        enforceMainLooper();
        return mCityModel.getCitySort();
    }

    /**
     * Adjust the order in which cities are sorted.
     */
    public void toggleCitySort() {
        enforceMainLooper();
        mCityModel.toggleCitySort();
    }

    /**
     * @param cityListener listener to be notified when the world city list changes
     */
    public void addCityListener(CityListener cityListener) {
        enforceMainLooper();
        mCityModel.addCityListener(cityListener);
    }

    /**
     * @param cityListener listener that no longer needs to be notified of world city list changes
     */
    public void removeCityListener(CityListener cityListener) {
        enforceMainLooper();
        mCityModel.removeCityListener(cityListener);
    }

    //
    // Timers
    //

    /**
     * @param timerListener to be notified when timers are added, updated and removed
     */
    public void addTimerListener(TimerListener timerListener) {
        enforceMainLooper();
        mTimerModel.addTimerListener(timerListener);
    }

    /**
     * @param timerListener to no longer be notified when timers are added, updated and removed
     */
    public void removeTimerListener(TimerListener timerListener) {
        enforceMainLooper();
        mTimerModel.removeTimerListener(timerListener);
    }

    /**
     * @return a list of timers for display
     */
    public List<Timer> getTimers() {
        enforceMainLooper();
        return mTimerModel.getTimers();
    }

    /**
     * @return a list of expired timers for display
     */
    public List<Timer> getExpiredTimers() {
        enforceMainLooper();
        return mTimerModel.getExpiredTimers();
    }

    /**
     * @param timerId identifies the timer to return
     * @return the timer with the given {@code timerId}
     */
    public Timer getTimer(int timerId) {
        enforceMainLooper();
        return mTimerModel.getTimer(timerId);
    }

    /**
     * @return the timer that last expired and is still expired now; {@code null} if no timers are
     *      expired
     */
    public Timer getMostRecentExpiredTimer() {
        enforceMainLooper();
        return mTimerModel.getMostRecentExpiredTimer();
    }

    /**
     * @param length the length of the timer in milliseconds
     * @param label describes the purpose of the timer
     * @param deleteAfterUse {@code true} indicates the timer should be deleted when it is reset
     * @return the newly added timer
     */
    public Timer addTimer(long length, String label, boolean deleteAfterUse) {
        enforceMainLooper();
        return mTimerModel.addTimer(length, label, deleteAfterUse);
    }

    /**
     * @param timer the timer to be removed
     */
    public void removeTimer(Timer timer) {
        enforceMainLooper();
        mTimerModel.removeTimer(timer);
    }

    /**
     * @param timer the timer to be started
     */
    public void startTimer(Timer timer) {
        startTimer(null, timer);
    }

    /**
     * @param service used to start foreground notifications for expired timers
     * @param timer the timer to be started
     */
    public void startTimer(Service service, Timer timer) {
        enforceMainLooper();
        final Timer started = timer.start();
        mTimerModel.updateTimer(started);
        if (timer.getRemainingTime() <= 0) {
            if (service != null) {
                expireTimer(service, started);
            } else {
                mContext.startService(TimerService.createTimerExpiredIntent(mContext, started));
            }
        }
    }

    /**
     * @param timer the timer to be paused
     */
    public void pauseTimer(Timer timer) {
        enforceMainLooper();
        mTimerModel.updateTimer(timer.pause());
    }

    /**
     * @param service used to start foreground notifications for expired timers
     * @param timer the timer to be expired
     */
    public void expireTimer(Service service, Timer timer) {
        enforceMainLooper();
        mTimerModel.expireTimer(service, timer);
    }

    /**
     * @param timer the timer to be reset
     * @return the reset {@code timer}
     */
    public Timer resetTimer(Timer timer) {
        enforceMainLooper();
        return mTimerModel.resetTimer(timer, false /* allowDelete */, 0 /* eventLabelId */);
    }

    /**
     * If the given {@code timer} is expired and marked for deletion after use then this method
     * removes the the timer. The timer is otherwise transitioned to the reset state and continues
     * to exist.
     *
     * @param timer the timer to be reset
     * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
     * @return the reset {@code timer} or {@code null} if the timer was deleted
     */
    public Timer resetOrDeleteTimer(Timer timer, @StringRes int eventLabelId) {
        enforceMainLooper();
        return mTimerModel.resetTimer(timer, true /* allowDelete */, eventLabelId);
    }

    /**
     * Resets all expired timers.
     *
     * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
     */
    public void resetOrDeleteExpiredTimers(@StringRes int eventLabelId) {
        enforceMainLooper();
        mTimerModel.resetOrDeleteExpiredTimers(eventLabelId);
    }

    /**
     * Resets all unexpired timers.
     *
     * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
     */
    public void resetUnexpiredTimers(@StringRes int eventLabelId) {
        enforceMainLooper();
        mTimerModel.resetUnexpiredTimers(eventLabelId);
    }

    /**
     * Resets all missed timers.
     *
     * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
     */
    public void resetMissedTimers(@StringRes int eventLabelId) {
        enforceMainLooper();
        mTimerModel.resetMissedTimers(eventLabelId);
    }

    /**
     * @param timer the timer to which a minute should be added to the remaining time
     */
    public void addTimerMinute(Timer timer) {
        enforceMainLooper();
        mTimerModel.updateTimer(timer.addMinute());
    }

    /**
     * @param timer the timer to which the new {@code label} belongs
     * @param label the new label to store for the {@code timer}
     */
    public void setTimerLabel(Timer timer, String label) {
        enforceMainLooper();
        mTimerModel.updateTimer(timer.setLabel(label));
    }

    /**
     * @param timer the timer whose {@code length} to change
     * @param length the new length of the timer in milliseconds
     */
    public void setTimerLength(Timer timer, long length) {
        enforceMainLooper();
        mTimerModel.updateTimer(timer.setLength(length));
    }

    /**
     * @param timer the timer whose {@code remainingTime} to change
     * @param remainingTime the new remaining time of the timer in milliseconds
     */
    public void setRemainingTime(Timer timer, long remainingTime) {
        enforceMainLooper();

        final Timer updated = timer.setRemainingTime(remainingTime);
        mTimerModel.updateTimer(updated);
        if (timer.isRunning() && timer.getRemainingTime() <= 0) {
            mContext.startService(TimerService.createTimerExpiredIntent(mContext, updated));
        }
    }

    /**
     * Updates the timer notifications to be current.
     */
    public void updateTimerNotification() {
        enforceMainLooper();
        mTimerModel.updateNotification();
    }

    /**
     * @return the uri of the default ringtone to play for all timers when no user selection exists
     */
    public Uri getDefaultTimerRingtoneUri() {
        enforceMainLooper();
        return mTimerModel.getDefaultTimerRingtoneUri();
    }

    /**
     * @return {@code true} iff the ringtone to play for all timers is the silent ringtone
     */
    public boolean isTimerRingtoneSilent() {
        enforceMainLooper();
        return mTimerModel.isTimerRingtoneSilent();
    }

    /**
     * @return the uri of the ringtone to play for all timers
     */
    public Uri getTimerRingtoneUri() {
        enforceMainLooper();
        return mTimerModel.getTimerRingtoneUri();
    }

    /**
     * @param uri the uri of the ringtone to play for all timers
     */
    public void setTimerRingtoneUri(Uri uri) {
        enforceMainLooper();
        mTimerModel.setTimerRingtoneUri(uri);
    }

    /**
     * @return the title of the ringtone that is played for all timers
     */
    public String getTimerRingtoneTitle() {
        enforceMainLooper();
        return mTimerModel.getTimerRingtoneTitle();
    }

    /**
     * @return the duration, in milliseconds, of the crescendo to apply to timer ringtone playback;
     *      {@code 0} implies no crescendo should be applied
     */
    public long getTimerCrescendoDuration() {
        enforceMainLooper();
        return mTimerModel.getTimerCrescendoDuration();
    }

    /**
     * @return whether vibrate is enabled for all timers.
     */
    public boolean getTimerVibrate() {
        enforceMainLooper();
        return mTimerModel.getTimerVibrate();
    }

    /**
     * @param enabled whether vibrate is enabled for all timers.
     */
    public void setTimerVibrate(boolean enabled) {
        enforceMainLooper();
        mTimerModel.setTimerVibrate(enabled);
    }

    //
    // Alarms
    //

    /**
     * @return the uri of the ringtone to which all new alarms default
     */
    public Uri getDefaultAlarmRingtoneUri() {
        enforceMainLooper();
        return mAlarmModel.getDefaultAlarmRingtoneUri();
    }

    /**
     * @param uri the uri of the ringtone to which future new alarms will default
     */
    public void setDefaultAlarmRingtoneUri(Uri uri) {
        enforceMainLooper();
        mAlarmModel.setDefaultAlarmRingtoneUri(uri);
    }

    /**
     * @return the duration, in milliseconds, of the crescendo to apply to alarm ringtone playback;
     *      {@code 0} implies no crescendo should be applied
     */
    public long getAlarmCrescendoDuration() {
        enforceMainLooper();
        return mAlarmModel.getAlarmCrescendoDuration();
    }

    /**
     * @return the behavior to execute when volume buttons are pressed while firing an alarm
     */
    public AlarmVolumeButtonBehavior getAlarmVolumeButtonBehavior() {
        enforceMainLooper();
        return mAlarmModel.getAlarmVolumeButtonBehavior();
    }

    /**
     * @return the number of minutes an alarm may ring before it has timed out and becomes missed
     */
    public int getAlarmTimeout() {
        return mAlarmModel.getAlarmTimeout();
    }

    /**
     * @return the number of minutes an alarm will remain snoozed before it rings again
     */
    public int getSnoozeLength() {
        return mAlarmModel.getSnoozeLength();
    }

    //
    // Stopwatch
    //

    /**
     * @param stopwatchListener to be notified when stopwatch changes or laps are added
     */
    public void addStopwatchListener(StopwatchListener stopwatchListener) {
        enforceMainLooper();
        mStopwatchModel.addStopwatchListener(stopwatchListener);
    }

    /**
     * @param stopwatchListener to no longer be notified when stopwatch changes or laps are added
     */
    public void removeStopwatchListener(StopwatchListener stopwatchListener) {
        enforceMainLooper();
        mStopwatchModel.removeStopwatchListener(stopwatchListener);
    }

    /**
     * @return the current state of the stopwatch
     */
    public Stopwatch getStopwatch() {
        enforceMainLooper();
        return mStopwatchModel.getStopwatch();
    }

    /**
     * @return the stopwatch after being started
     */
    public Stopwatch startStopwatch() {
        enforceMainLooper();
        return mStopwatchModel.setStopwatch(getStopwatch().start());
    }

    /**
     * @return the stopwatch after being paused
     */
    public Stopwatch pauseStopwatch() {
        enforceMainLooper();
        return mStopwatchModel.setStopwatch(getStopwatch().pause());
    }

    /**
     * @return the stopwatch after being reset
     */
    public Stopwatch resetStopwatch() {
        enforceMainLooper();
        return mStopwatchModel.setStopwatch(getStopwatch().reset());
    }

    /**
     * @return the laps recorded for this stopwatch
     */
    public List<Lap> getLaps() {
        enforceMainLooper();
        return mStopwatchModel.getLaps();
    }

    /**
     * @return a newly recorded lap completed now; {@code null} if no more laps can be added
     */
    public Lap addLap() {
        enforceMainLooper();
        return mStopwatchModel.addLap();
    }

    /**
     * @return {@code true} iff more laps can be recorded
     */
    public boolean canAddMoreLaps() {
        enforceMainLooper();
        return mStopwatchModel.canAddMoreLaps();
    }

    /**
     * @return the longest lap time of all recorded laps and the current lap
     */
    public long getLongestLapTime() {
        enforceMainLooper();
        return mStopwatchModel.getLongestLapTime();
    }

    /**
     * @param time a point in time after the end of the last lap
     * @return the elapsed time between the given {@code time} and the end of the previous lap
     */
    public long getCurrentLapTime(long time) {
        enforceMainLooper();
        return mStopwatchModel.getCurrentLapTime(time);
    }

    //
    // Time
    // (Time settings/values are accessible from any Thread so no Thread-enforcement exists.)
    //

    /**
     * @return the current time in milliseconds
     */
    public long currentTimeMillis() {
        return mTimeModel.currentTimeMillis();
    }

    /**
     * @return milliseconds since boot, including time spent in sleep
     */
    public long elapsedRealtime() {
        return mTimeModel.elapsedRealtime();
    }

    /**
     * @return {@code true} if 24 hour time format is selected; {@code false} otherwise
     */
    public boolean is24HourFormat() {
        return mTimeModel.is24HourFormat();
    }

    /**
     * @return a new calendar object initialized to the {@link #currentTimeMillis()}
     */
    public Calendar getCalendar() {
        return mTimeModel.getCalendar();
    }

    //
    // Ringtones
    //

    /**
     * Ringtone titles are cached because loading them is expensive. This method
     * <strong>must</strong> be called on a background thread and is responsible for priming the
     * cache of ringtone titles to avoid later fetching titles on the main thread.
     */
    public void loadRingtoneTitles() {
        enforceNotMainLooper();
        mRingtoneModel.loadRingtoneTitles();
    }

    /**
     * Recheck the permission to read each custom ringtone.
     */
    public void loadRingtonePermissions() {
        enforceNotMainLooper();
        mRingtoneModel.loadRingtonePermissions();
    }

    /**
     * @param uri the uri of a ringtone
     * @return the title of the ringtone with the {@code uri}; {@code null} if it cannot be fetched
     */
    public String getRingtoneTitle(Uri uri) {
        enforceMainLooper();
        return mRingtoneModel.getRingtoneTitle(uri);
    }

    /**
     * @param uri the uri of an audio file to use as a ringtone
     * @param title the title of the audio content at the given {@code uri}
     * @return the ringtone instance created for the audio file
     */
    public CustomRingtone addCustomRingtone(Uri uri, String title) {
        enforceMainLooper();
        return mRingtoneModel.addCustomRingtone(uri, title);
    }

    /**
     * @param uri identifies the ringtone to remove
     */
    public void removeCustomRingtone(Uri uri) {
        enforceMainLooper();
        mRingtoneModel.removeCustomRingtone(uri);
    }

    /**
     * @return all available custom ringtones
     */
    public List<CustomRingtone> getCustomRingtones() {
        enforceMainLooper();
        return mRingtoneModel.getCustomRingtones();
    }

    //
    // Widgets
    //

    /**
     * @param widgetClass indicates the type of widget being counted
     * @param count the number of widgets of the given type
     * @param eventCategoryId identifies the category of event to send
     */
    public void updateWidgetCount(Class widgetClass, int count, @StringRes int eventCategoryId) {
        enforceMainLooper();
        mWidgetModel.updateWidgetCount(widgetClass, count, eventCategoryId);
    }

    //
    // Settings
    //

    /**
     * @param silentSettingsListener to be notified when alarm-silencing settings change
     */
    public void addSilentSettingsListener(OnSilentSettingsListener silentSettingsListener) {
        enforceMainLooper();
        mSilentSettingsModel.addSilentSettingsListener(silentSettingsListener);
    }

    /**
     * @param silentSettingsListener to no longer be notified when alarm-silencing settings change
     */
    public void removeSilentSettingsListener(OnSilentSettingsListener silentSettingsListener) {
        enforceMainLooper();
        mSilentSettingsModel.removeSilentSettingsListener(silentSettingsListener);
    }

    /**
     * @return the id used to discriminate relevant AlarmManager callbacks from defunct ones
     */
    public int getGlobalIntentId() {
        return mSettingsModel.getGlobalIntentId();
    }

    /**
     * Update the id used to discriminate relevant AlarmManager callbacks from defunct ones
     */
    public void updateGlobalIntentId() {
        enforceMainLooper();
        mSettingsModel.updateGlobalIntentId();
    }

    /**
     * @return the style of clock to display in the clock application
     */
    public ClockStyle getClockStyle() {
        enforceMainLooper();
        return mSettingsModel.getClockStyle();
    }

    /**
     * @return the style of clock to display in the clock application
     */
    public boolean getDisplayClockSeconds() {
        enforceMainLooper();
        return mSettingsModel.getDisplayClockSeconds();
    }

    /**
     * @param displaySeconds whether or not to display seconds for main clock
     */
    public void setDisplayClockSeconds(boolean displaySeconds) {
        enforceMainLooper();
        mSettingsModel.setDisplayClockSeconds(displaySeconds);
    }

    /**
     * @return the style of clock to display in the clock screensaver
     */
    public ClockStyle getScreensaverClockStyle() {
        enforceMainLooper();
        return mSettingsModel.getScreensaverClockStyle();
    }

    /**
     * @return {@code true} if the screen saver should be dimmed for lower contrast at night
     */
    public boolean getScreensaverNightModeOn() {
        enforceMainLooper();
        return mSettingsModel.getScreensaverNightModeOn();
    }

    /**
     * @return {@code true} if the users wants to automatically show a clock for their home timezone
     *      when they have travelled outside of that timezone
     */
    public boolean getShowHomeClock() {
        enforceMainLooper();
        return mSettingsModel.getShowHomeClock();
    }

    /**
     * @return the display order of the weekdays, which can start with {@link Calendar#SATURDAY},
     *      {@link Calendar#SUNDAY} or {@link Calendar#MONDAY}
     */
    public Weekdays.Order getWeekdayOrder() {
        enforceMainLooper();
        return mSettingsModel.getWeekdayOrder();
    }

    /**
     * @return {@code true} if the restore process (of backup and restore) has completed
     */
    public boolean isRestoreBackupFinished() {
        return mSettingsModel.isRestoreBackupFinished();
    }

    /**
     * @param finished {@code true} means the restore process (of backup and restore) has completed
     */
    public void setRestoreBackupFinished(boolean finished) {
        mSettingsModel.setRestoreBackupFinished(finished);
    }

    /**
     * @return a description of the time zones available for selection
     */
    public TimeZones getTimeZones() {
        enforceMainLooper();
        return mSettingsModel.getTimeZones();
    }

    /**
     * Used to execute a delegate runnable and track its completion.
     */
    private static class ExecutedRunnable implements Runnable {

        private final Runnable mDelegate;
        private boolean mExecuted;

        private ExecutedRunnable(Runnable delegate) {
            this.mDelegate = delegate;
        }

        @Override
        public void run() {
            mDelegate.run();

            synchronized (this) {
                mExecuted = true;
                notifyAll();
            }
        }

        private boolean isExecuted() {
            return mExecuted;
        }
    }
}
