/*
 * Copyright 2013 Canonical Ltd.
 * Copyright 2021 Robert Tari
 *
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 3, as published
 * by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranties of
 * MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
 * PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * Authors:
 *   Charles Kerr <charles.kerr@canonical.com>
 *   Robert Tari <robert@tari.in>
 */

#include <datetime/menu.h>
#include <datetime/formatter.h>
#include <datetime/state.h>
#include <glib/gi18n.h>
#include <gio/gio.h>
#include <algorithm>
#include <iterator>
#include <vector>

extern "C"
{
    #include <ayatana/common/utils.h>
}

namespace ayatana {
namespace indicator {
namespace datetime {

/****
*****
****/

Menu::Menu (Profile profile_in, const std::string& name_in):
    m_profile(profile_in),
    m_name(name_in)
{
}

const std::string& Menu::name() const
{
    return m_name;
}

Menu::Profile Menu::profile() const
{
    return m_profile;
}

GMenuModel* Menu::menu_model()
{
    return G_MENU_MODEL(m_menu);
}

/**
 * To avoid a giant menu on the PC, and to avoid pushing lower menu items
 * off-screen on the phone, the menu should show the
 * next five calendar events, if any.
 *
 * The list might include multiple occurrences of the same event (bug 1515821).
 */
std::vector<Appointment>
Menu::get_display_appointments(const std::vector<Appointment>& appointments_in,
                               const DateTime& now,
                               unsigned int max_items)
{
    std::vector<Appointment> appointments;
    std::copy_if(appointments_in.begin(),
                 appointments_in.end(),
                 std::back_inserter(appointments),
                 [now](const Appointment& a){return a.end >= now;});

    if (appointments.size() > max_items)
    {
        const auto next_minute = now.add_full(0,0,0,0,1,-now.seconds());
        const auto start_of_day = now.start_of_day();
        const auto end_of_day = now.end_of_day();

        /*
         * If there are more than five, the events shown should be, in order of priority:
         * 1. any events that start or end (bug 1329048) after the current minute today;
         * 2. any full-day events that span all of today (bug 1302004);
         * 3. any events that start or end tomorrow;
         * 4. any events that start or end the day after tomorrow; and so on.
         */
        auto compare = [next_minute, start_of_day, end_of_day](
            const Appointment& a,
            const Appointment& b)
        {
            const bool a_later_today = (a.begin >= next_minute) || (a.end <= end_of_day);
            const bool b_later_today = (b.begin >= next_minute) || (b.end <= end_of_day);
            if (a_later_today != b_later_today)
                return a_later_today;

            const bool a_full_day_today = (a.begin <= start_of_day) && (end_of_day <= a.end);
            const bool b_full_day_today = (b.begin <= start_of_day) && (end_of_day <= b.end);
            if (a_full_day_today != b_full_day_today)
                return a_full_day_today;

            if (a.begin != b.begin)
                return a.begin < b.begin;
            if (b.end != b.end)
                return a.end < b.end;

            return false;
        };
        std::sort(appointments.begin(), appointments.end(), compare);
        appointments.resize(max_items);
    }

    /*
     * However, the display order should be the reverse: full-day events
     * first (since they start first), part-day events afterward in
     * chronological order. If multiple events have exactly the same start+end
     * time, they should be sorted alphabetically.
     */
    auto compare = [](const Appointment& a, const Appointment& b)
    {
        if (a.begin != b.begin)
            return a.begin < b.begin;

        if (a.end != b.end)
            return a.end < b.end;

        return a.summary < b.summary;
    };
    std::sort(appointments.begin(), appointments.end(), compare);
    return appointments;
}

/****
*****
****/

#define ALARM_ICON_NAME "alarm-clock"
#define CALENDAR_ICON_NAME "calendar"

class MenuImpl: public Menu
{
protected:
    MenuImpl(const Menu::Profile profile_in,
             const std::string& name_in,
             std::shared_ptr<const State>& state,
             std::shared_ptr<Actions>& actions,
             std::shared_ptr<const Formatter> formatter):
        Menu(profile_in, name_in),
        m_state(state),
        m_actions(actions),
        m_formatter(formatter)
    {
        if (m_pSettings == NULL && profile_in != Phone && profile_in != PhoneGreeter)
        {
            m_pSettings = g_settings_new("org.ayatana.common");
            g_signal_connect(m_pSettings, "changed::max-menu-text-length", G_CALLBACK(on_string_length_changed), this);
        }

        // initialize the menu
        create_gmenu();
        for (int i=0; i<NUM_SECTIONS; i++)
            update_section(Section(i));

        // listen for state changes so we can update the menu accordingly
        m_formatter->header.changed().connect([this](const std::string&){
            update_header();
        });
        m_formatter->header_format.changed().connect([this](const std::string&){
            update_section(Locations); // need to update x-ayatana-time-format
        });
        m_formatter->relative_format_changed.connect([this](){
            update_section(Appointments); // uses formatter.relative_format()
            update_section(Locations); // uses formatter.relative_format()
        });
        m_state->settings->show_calendar.changed().connect([this](bool){
            update_section(Calendar);
        });
        m_state->settings->show_events.changed().connect([this](bool){
            update_section(Appointments); // showing events got toggled
        });
        m_state->calendar_upcoming->date().changed().connect([this](const DateTime&){
            update_upcoming(); // our m_upcoming is planner->upcoming() filtered by time
        });
        m_state->calendar_upcoming->appointments().changed().connect([this](const std::vector<Appointment>&){
            update_upcoming(); // our m_upcoming is planner->upcoming() filtered by time
        });
        m_state->clock->date_changed.connect([this](){
            update_section(Calendar); // need to update the Date menuitem
            update_section(Locations); // locations' relative time may have changed
        });
        m_state->clock->minute_changed.connect([this](){
            update_upcoming(); // our m_upcoming is planner->upcoming() filtered by time
        });
        m_state->locations->locations.changed().connect([this](const std::vector<Location>&) {
            update_section(Locations); // "locations" is the list of Locations we show
        });
    }

    virtual ~MenuImpl()
    {
        g_clear_object(&m_menu);
        g_clear_pointer(&m_serialized_alarm_icon, g_variant_unref);
        g_clear_pointer(&m_serialized_calendar_icon, g_variant_unref);
        g_clear_object(&m_pSettings);
    }

    virtual GVariant* create_header_state() =0;

    static void on_string_length_changed(GSettings *pSettings, gchar *sKey, gpointer pSelf)
    {
        static_cast<MenuImpl*>(pSelf)->update_section(Appointments);
    }

    void update_header()
    {
        auto action_group = m_actions->action_group();
        auto action_name = name() + "-header";
        auto state = create_header_state();
        g_action_group_change_action_state(action_group, action_name.c_str(), state);
    }

    void update_upcoming()
    {
        // The usual case is to show events germane to the current time.
        // However when the user clicks onto a different calendar date,
        // we pick events starting from the beginning of that clicked day.
        const auto now = m_state->clock->localtime();
        const auto calendar_day = m_state->calendar_month->month().get();
        const auto begin = DateTime::is_same_day(now, calendar_day)
            ? now.start_of_minute()
            : calendar_day.start_of_day();

        auto upcoming = get_display_appointments(
            m_state->calendar_upcoming->appointments().get(),
            begin
        );

        if (m_upcoming != upcoming)
        {
            m_upcoming.swap(upcoming);
            update_header(); // show an 'alarm' icon if there are upcoming alarms
            update_section(Appointments); // "upcoming" is the list of Appointments we show
        }
    }

    std::shared_ptr<const State> m_state;
    std::shared_ptr<Actions> m_actions;
    std::shared_ptr<const Formatter> m_formatter;
    GMenu* m_submenu = nullptr;

    GVariant* get_serialized_alarm_icon()
    {
        if (G_UNLIKELY(m_serialized_alarm_icon == nullptr))
        {
            auto i = g_themed_icon_new_with_default_fallbacks(ALARM_ICON_NAME);
            m_serialized_alarm_icon = g_icon_serialize(i);
            g_object_unref(i);
        }

        return m_serialized_alarm_icon;
    }

    std::vector<Appointment> m_upcoming;

private:

    GVariant* get_serialized_calendar_icon()
    {
        if (G_UNLIKELY(m_serialized_calendar_icon == nullptr))
        {
            auto i = g_themed_icon_new_with_default_fallbacks(CALENDAR_ICON_NAME);
            m_serialized_calendar_icon = g_icon_serialize(i);
            g_object_unref(i);
        }

        return m_serialized_calendar_icon;
    }

    void create_gmenu()
    {
        g_assert(m_submenu == nullptr);

        m_submenu = g_menu_new();

        // build placeholders for the sections
        for(int i=0; i<NUM_SECTIONS; i++)
        {
            GMenuItem * item = g_menu_item_new(nullptr, nullptr);
            g_menu_append_item(m_submenu, item);
            g_object_unref(item);
        }

        // add submenu to the header
        const auto detailed_action = std::string("indicator.") + name() + "-header";
        auto header = g_menu_item_new(nullptr, detailed_action.c_str());
        g_menu_item_set_attribute(header, "x-ayatana-type", "s",
                                  "org.ayatana.indicator.root");
        g_menu_item_set_attribute(header, "submenu-action", "s",
                                  "indicator.calendar-active");
        g_menu_item_set_submenu(header, G_MENU_MODEL(m_submenu));
        g_object_unref(m_submenu);

        // add header to the menu
        m_menu = g_menu_new();
        g_menu_append_item(m_menu, header);
        g_object_unref(header);
    }

    GMenuModel* create_calendar_section(Profile profile)
    {
        const bool show_calendar = m_state->settings->show_calendar.get() &&
                                   ((profile == Desktop) || (profile == DesktopGreeter) || (profile == Phone));
        auto menu = g_menu_new();

        const char * action_name;

        if (profile == Phone)
            action_name = "indicator.phone.open-calendar-app";
        else if (profile == Desktop)
            action_name = "indicator.desktop.open-calendar-app";
        else
            action_name = nullptr;

        /* Translators, please edit/rearrange these strftime(3) tokens to suit your locale!
           Format string for the day on the first menuitem in the datetime indicator.
           This format string gives the full weekday, date, month, and year.
           en_US example: "%A, %B %e %Y" --> Saturday, October 31 2020"
           en_GB example: "%A, %e %B %Y" --> Saturday, 31 October 2020" */
        auto label = m_state->clock->localtime().format(_("%A, %e %B %Y"));
        auto item = g_menu_item_new (label.c_str(), nullptr);
        auto v = get_serialized_calendar_icon();
        g_menu_item_set_attribute_value (item, G_MENU_ATTRIBUTE_ICON, v);
        if (action_name != nullptr)
        {
            v = g_variant_new_int64(0);
            g_menu_item_set_action_and_target_value (item, action_name, v);
        }
        g_menu_append_item(menu, item);
        g_object_unref(item);

        // add calendar
        if (show_calendar)
        {
            item = g_menu_item_new ("[calendar]", nullptr);
            v = g_variant_new_int64(0);
            g_menu_item_set_action_and_target_value (item, "indicator.calendar", v);
            g_menu_item_set_attribute (item, "x-ayatana-type",
                                       "s", "org.ayatana.indicator.calendar");
            if (action_name != nullptr)
                g_menu_item_set_attribute (item, "activation-action", "s", action_name);
            g_menu_append_item (menu, item);
            g_object_unref (item);
        }

        return G_MENU_MODEL(menu);
    }

    void add_appointments(GMenu* menu, Profile profile)
    {
        const int MAX_APPTS = 5;
        std::set<std::string> added;

        const char * action_name;

        if (profile == Phone)
            action_name = "indicator.phone.open-appointment";
        else if ((profile == Desktop) && m_actions->desktop_has_calendar_app())
            action_name = "indicator.desktop.open-appointment";
        else
            action_name = nullptr;

        for (const auto& appt : m_upcoming)
        {
            // don't show duplicates
            if (added.count(appt.uid))
                continue;

            // don't show too many
            if (g_menu_model_get_n_items (G_MENU_MODEL(menu)) >= MAX_APPTS)
                break;

            added.insert(appt.uid);

            GDateTime* begin = appt.begin();
            GDateTime* end = appt.end();
            auto fmt = m_formatter->relative_format(begin, end);
            auto unix_time = g_date_time_to_unix(begin);

            ayatana_common_utils_ellipsize((char*)appt.summary.c_str());
            auto menu_item = g_menu_item_new (appt.summary.c_str(), nullptr);
            g_menu_item_set_attribute (menu_item, "x-ayatana-time", "x", unix_time);
            g_menu_item_set_attribute (menu_item, "x-ayatana-time-format", "s", fmt.c_str());

            if (appt.is_alarm())
            {
                g_menu_item_set_attribute (menu_item, "x-ayatana-type", "s", "org.ayatana.indicator.alarm");
                g_menu_item_set_attribute_value(menu_item, G_MENU_ATTRIBUTE_ICON, get_serialized_alarm_icon());
            }
            else
            {
                g_menu_item_set_attribute (menu_item, "x-ayatana-type", "s", "org.ayatana.indicator.appointment");
            }

            if (!appt.color.empty())
                g_menu_item_set_attribute (menu_item, "x-ayatana-color", "s", appt.color.c_str());

            if (action_name != nullptr) {
                g_menu_item_set_action_and_target_value (menu_item, action_name,
                                                         g_variant_new ("(sx)",
                                                                        appt.uid.c_str(),
                                                                        unix_time));
            }

            g_menu_append_item (menu, menu_item);
            g_object_unref (menu_item);
        }
    }

    GMenuModel* create_appointments_section(Profile profile)
    {
        auto menu = g_menu_new();

        if ((profile==Desktop) && m_state->settings->show_events.get())
        {
            add_appointments (menu, profile);

            if (m_actions->desktop_has_calendar_app())
            {
                // add the 'Add Event…' menuitem
                auto menu_item = g_menu_item_new(_("Add Event…"), nullptr);
                const gchar* action_name = "indicator.desktop.open-calendar-app";
                auto v = g_variant_new_int64(0);
                g_menu_item_set_action_and_target_value(menu_item, action_name, v);
                g_menu_append_item(menu, menu_item);
                g_object_unref(menu_item);
            }
        }
        else if (profile==Phone)
        {
            auto menu_item = g_menu_item_new (_("Clock"), "indicator.phone.open-alarm-app");
            g_menu_item_set_attribute_value (menu_item, G_MENU_ATTRIBUTE_ICON, get_serialized_alarm_icon());
            g_menu_append_item (menu, menu_item);
            g_object_unref (menu_item);

            add_appointments (menu, profile);
        }

        return G_MENU_MODEL(menu);
    }

    GMenuModel* create_locations_section(Profile profile)
    {
        GMenu* menu = g_menu_new();

        const auto now = m_state->clock->localtime();

        if (profile == Desktop || profile == Phone)
        {
            for(const auto& location : m_state->locations->locations.get())
            {
                const auto& zone = location.zone();
                const auto& name = location.name();
                const auto zone_now = now.to_timezone(zone);
                const auto fmt = m_formatter->relative_format(zone_now.get());
                auto detailed_action = g_strdup_printf("indicator.set-location::%s %s", zone.c_str(), name.c_str());
                auto i = g_menu_item_new (name.c_str(), detailed_action);
                g_menu_item_set_attribute(i, "x-ayatana-type", "s", "org.ayatana.indicator.location");
                g_menu_item_set_attribute(i, "x-ayatana-timezone", "s", zone.c_str());
                g_menu_item_set_attribute(i, "x-ayatana-time-format", "s", fmt.c_str());
                g_menu_append_item (menu, i);
                g_object_unref(i);
                g_free(detailed_action);
            }
        }

        return G_MENU_MODEL(menu);
    }

    GMenuModel* create_settings_section(Profile profile)
    {
        auto menu = g_menu_new();
        const char * action_name;

        if (profile == Desktop)
            action_name = "indicator.desktop.open-settings-app";
        else if (profile == Phone)
            action_name = "indicator.phone.open-settings-app";
        else
            action_name = nullptr;

        if (action_name != nullptr)
            g_menu_append (menu, _("Time and Date Settings…"), action_name);

        return G_MENU_MODEL (menu);
    }

    void update_section(Section section)
    {
        GMenuModel * model;
        const auto p = profile();

        switch (section)
        {
            case Calendar: model = create_calendar_section(p); break;
            case Appointments: model = create_appointments_section(p); break;
            case Locations: model = create_locations_section(p); break;
            case Settings: model = create_settings_section(p); break;
            default: model = nullptr; g_warn_if_reached();
        }

        if (model)
        {
            g_menu_remove(m_submenu, section);
            g_menu_insert_section(m_submenu, section, nullptr, model);
            g_object_unref(model);
        }
    }

//private:
    GVariant * m_serialized_alarm_icon = nullptr;
    GVariant * m_serialized_calendar_icon = nullptr;
    GSettings *m_pSettings = NULL;

}; // class MenuImpl



/***
****
***/

class DesktopBaseMenu: public MenuImpl
{
protected:
    DesktopBaseMenu(Menu::Profile profile_,
                    const std::string& name_,
                    std::shared_ptr<const State>& state_,
                    std::shared_ptr<Actions>& actions_):
        MenuImpl(profile_, name_, state_, actions_,
                 std::shared_ptr<const Formatter>(new DesktopFormatter(state_->clock, state_->settings)))
    {
        update_header();
    }

    GVariant* create_header_state() override
    {
        const auto title = _("Date and Time");
        auto label = g_variant_new_string(m_formatter->header.get().c_str());

        GVariantBuilder b;
        g_variant_builder_init(&b, G_VARIANT_TYPE_VARDICT);
        g_variant_builder_add(&b, "{sv}", "accessible-desc", label);
        g_variant_builder_add(&b, "{sv}", "label", label);
        g_variant_builder_add(&b, "{sv}", "title", g_variant_new_string(title));
        g_variant_builder_add(&b, "{sv}", "visible", g_variant_new_boolean(TRUE));
        g_variant_builder_add(&b, "{sv}", "tooltip", g_variant_new_string (_("Time & date settings, quick calendar access")));
        return g_variant_builder_end(&b);
    }
};

class DesktopMenu: public DesktopBaseMenu
{
public:
    DesktopMenu(std::shared_ptr<const State>& state_, std::shared_ptr<Actions>& actions_):
        DesktopBaseMenu(Desktop,"desktop", state_, actions_) {}
};

class DesktopGreeterMenu: public DesktopBaseMenu
{
public:
    DesktopGreeterMenu(std::shared_ptr<const State>& state_, std::shared_ptr<Actions>& actions_):
        DesktopBaseMenu(DesktopGreeter,"desktop_greeter", state_, actions_) {}
};


class PhoneBaseMenu: public MenuImpl
{
protected:
    PhoneBaseMenu(Menu::Profile profile_,
                  const std::string& name_,
                  std::shared_ptr<const State>& state_,
                  std::shared_ptr<Actions>& actions_):
        MenuImpl(profile_, name_, state_, actions_,
                 std::shared_ptr<Formatter>(new PhoneFormatter(state_->clock)))
    {
        update_header();
    }

    GVariant* create_header_state() override
    {
        // are there alarms?
        bool has_alarms = false;
        for(const auto& appointment : m_upcoming)
        {
            has_alarms = appointment.is_alarm();

            if (has_alarms)
            {
                break;
            }
        }

        GVariantBuilder b;
        g_variant_builder_init(&b, G_VARIANT_TYPE_VARDICT);
        g_variant_builder_add(&b, "{sv}", "title", g_variant_new_string (_("Time and Date")));
        g_variant_builder_add(&b, "{sv}", "visible", g_variant_new_boolean (TRUE));
        if (has_alarms)
        {
            auto label = m_formatter->header.get();
            auto a11y = g_strdup_printf(_("%s (has alarms)"), label.c_str());
            g_variant_builder_add(&b, "{sv}", "label", g_variant_new_string(label.c_str()));
            g_variant_builder_add(&b, "{sv}", "accessible-desc", g_variant_new_take_string(a11y));
            g_variant_builder_add(&b, "{sv}", "icon", get_serialized_alarm_icon());
        }
        else
        {
            auto v = g_variant_new_string(m_formatter->header.get().c_str());
            g_variant_builder_add(&b, "{sv}", "label", v);
            g_variant_builder_add(&b, "{sv}", "accessible-desc", v);
        }
        return g_variant_builder_end (&b);
    }
};

class PhoneMenu: public PhoneBaseMenu
{
public:
    PhoneMenu(std::shared_ptr<const State>& state_,
              std::shared_ptr<Actions>& actions_):
        PhoneBaseMenu(Phone, "phone", state_, actions_) {}
};

class PhoneGreeterMenu: public PhoneBaseMenu
{
public:
    PhoneGreeterMenu(std::shared_ptr<const State>& state_,
                     std::shared_ptr<Actions>& actions_):
        PhoneBaseMenu(PhoneGreeter, "phone_greeter", state_, actions_) {}
};

/****
*****
****/

MenuFactory::MenuFactory(const std::shared_ptr<Actions>& actions_,
                         const std::shared_ptr<const State>& state_):
    m_actions(actions_),
    m_state(state_)
{
}

std::shared_ptr<Menu>
MenuFactory::buildMenu(Menu::Profile profile)
{
    std::shared_ptr<Menu> menu;

    switch (profile)
    {
    case Menu::Desktop:
        menu.reset(new DesktopMenu(m_state, m_actions));
        break;

    case Menu::DesktopGreeter:
        menu.reset(new DesktopGreeterMenu(m_state, m_actions));
        break;

    case Menu::Phone:
        menu.reset(new PhoneMenu(m_state, m_actions));
        break;

    case Menu::PhoneGreeter:
        menu.reset(new PhoneGreeterMenu(m_state, m_actions));
        break;

    default:
        g_warn_if_reached();
        break;
    }

    return menu;
}

/****
*****
****/

} // namespace datetime
} // namespace indicator
} // namespace ayatana
