/*
    This file is part of the KDE project
    SPDX-FileCopyrightText: 2021 Felix Ernst <fe.a.ernst@gmail.com>

    SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
*/

#include "khamburgermenu.h"
#include "khamburgermenu_p.h"

#include "khamburgermenuhelpers_p.h"

#include <KLocalizedString>

#include <QMenu>
#include <QMenuBar>
#include <QStyle>
#include <QToolBar>
#include <QToolButton>

#include <algorithm>
#include <forward_list>
#include <unordered_set>

KHamburgerMenu::KHamburgerMenu(QObject *parent)
    : QWidgetAction{parent}
    , d_ptr{new KHamburgerMenuPrivate(this)}
{
}

KHamburgerMenuPrivate::KHamburgerMenuPrivate(KHamburgerMenu *qq)
    : q_ptr{qq}
    , m_listeners{new ListenerContainer(this)}
{
    q_ptr->setPriority(QAction::LowPriority);
    connect(q_ptr, &QAction::changed, this, &KHamburgerMenuPrivate::slotActionChanged);
}

KHamburgerMenu::~KHamburgerMenu() = default;

KHamburgerMenuPrivate::~KHamburgerMenuPrivate() = default;

void KHamburgerMenu::setMenuBar(QMenuBar *menuBar)
{
    Q_D(KHamburgerMenu);
    d->setMenuBar(menuBar);
}

void KHamburgerMenuPrivate::setMenuBar(QMenuBar *menuBar)
{
    if (m_menuBar) {
        m_menuBar->removeEventFilter(m_listeners->get<VisibilityChangesListener>());
        m_menuBar->removeEventFilter(m_listeners->get<AddOrRemoveActionListener>());
    }
    m_menuBar = menuBar;
    updateVisibility();
    if (m_menuBar) {
        m_menuBar->installEventFilter(m_listeners->get<VisibilityChangesListener>());
        m_menuBar->installEventFilter(m_listeners->get<AddOrRemoveActionListener>());
    }
}

QMenuBar *KHamburgerMenu::menuBar() const
{
    Q_D(const KHamburgerMenu);
    return d->menuBar();
}

QMenuBar *KHamburgerMenuPrivate::menuBar() const
{
    return m_menuBar;
}

void KHamburgerMenu::setMenuBarAdvertised(bool advertise)
{
    Q_D(KHamburgerMenu);
    d->setMenuBarAdvertised(advertise);
}

void KHamburgerMenuPrivate::setMenuBarAdvertised(bool advertise)
{
    m_advertiseMenuBar = advertise;
}

bool KHamburgerMenu::menuBarAdvertised() const
{
    Q_D(const KHamburgerMenu);
    return d->menuBarAdvertised();
}

bool KHamburgerMenuPrivate::menuBarAdvertised() const
{
    return m_advertiseMenuBar;
}

void KHamburgerMenu::setShowMenuBarAction(QAction *showMenuBarAction)
{
    Q_D(KHamburgerMenu);
    d->setShowMenuBarAction(showMenuBarAction);
}

void KHamburgerMenuPrivate::setShowMenuBarAction(QAction *showMenuBarAction)
{
    m_showMenuBarAction = showMenuBarAction;
}

void KHamburgerMenu::addToMenu(QMenu *menu)
{
    Q_D(KHamburgerMenu);
    d->insertIntoMenuBefore(menu, nullptr);
}

void KHamburgerMenu::insertIntoMenuBefore(QMenu *menu, QAction *before)
{
    Q_D(KHamburgerMenu);
    d->insertIntoMenuBefore(menu, before);
}

void KHamburgerMenuPrivate::insertIntoMenuBefore(QMenu *menu, QAction *before)
{
    Q_CHECK_PTR(menu);
    Q_Q(KHamburgerMenu);
    if (!m_menuAction) {
        m_menuAction = new QAction(this);
        m_menuAction->setText(i18nc("@action:inmenu General purpose menu", "&Menu"));
        m_menuAction->setIcon(q->icon());
        m_menuAction->setMenu(m_actualMenu.get());
    }
    updateVisibility(); // Sets the appropriate visibility of m_menuAction.

    menu->insertAction(before, m_menuAction);
    connect(menu, &QMenu::aboutToShow, this, [this, menu, q]() {
        if (m_menuAction->isVisible()) {
            Q_EMIT q->aboutToShowMenu();
            hideActionsOf(menu);
            resetMenu();
        }
    });
}

void KHamburgerMenu::hideActionsOf(QWidget *widget)
{
    Q_D(KHamburgerMenu);
    d->hideActionsOf(widget);
}

void KHamburgerMenuPrivate::hideActionsOf(QWidget *widget)
{
    Q_CHECK_PTR(widget);
    m_widgetsWithActionsToBeHidden.remove(nullptr);
    if (listContainsWidget(m_widgetsWithActionsToBeHidden, widget)) {
        return;
    }
    m_widgetsWithActionsToBeHidden.emplace_front(QPointer<const QWidget>(widget));
    if (QMenu *menu = qobject_cast<QMenu *>(widget)) {
        // QMenus are normally hidden. This will avoid redundancy with their actions anyways.
        menu->installEventFilter(m_listeners->get<AddOrRemoveActionListener>());
        notifyMenuResetNeeded();
    } else {
        // Only avoid redundancy when the widget is visible.
        widget->installEventFilter(m_listeners->get<VisibleActionsChangeListener>());
        if (widget->isVisible()) {
            notifyMenuResetNeeded();
        }
    }
}

void KHamburgerMenu::showActionsOf(QWidget *widget)
{
    Q_D(KHamburgerMenu);
    d->showActionsOf(widget);
}

void KHamburgerMenuPrivate::showActionsOf(QWidget *widget)
{
    Q_CHECK_PTR(widget);
    m_widgetsWithActionsToBeHidden.remove(widget);
    widget->removeEventFilter(m_listeners->get<AddOrRemoveActionListener>());
    widget->removeEventFilter(m_listeners->get<VisibleActionsChangeListener>());
    if (isWidgetActuallyVisible(widget)) {
        notifyMenuResetNeeded();
    }
}

QWidget *KHamburgerMenu::createWidget(QWidget *parent)
{
    Q_D(KHamburgerMenu);
    return d->createWidget(parent);
}

QWidget *KHamburgerMenuPrivate::createWidget(QWidget *parent)
{
    if (qobject_cast<QMenu *>(parent)) {
        qDebug(
            "Adding a KHamburgerMenu directly to a QMenu. "
            "This will look odd. Use addToMenu() instead.");
    }
    Q_Q(KHamburgerMenu);

    auto toolButton = new QToolButton(parent);
    // Set appearance
    toolButton->setDefaultAction(q);
    toolButton->setMenu(m_actualMenu.get());
    toolButton->setAttribute(Qt::WidgetAttribute::WA_CustomWhatsThis);
    toolButton->setPopupMode(QToolButton::InstantPopup);
    updateButtonStyle(toolButton);
    if (const QToolBar *toolbar = qobject_cast<QToolBar *>(parent)) {
        connect(toolbar, &QToolBar::toolButtonStyleChanged, toolButton, &QToolButton::setToolButtonStyle);
    }

    // Make sure the menu will be ready in time
    toolButton->installEventFilter(m_listeners->get<ButtonPressListener>());

    hideActionsOf(parent);
    return toolButton;
}

QAction *KHamburgerMenuPrivate::actionWithExclusivesFrom(QAction *from, QWidget *parent, std::unordered_set<const QAction *> &nonExclusives) const
{
    Q_CHECK_PTR(from);
    if (nonExclusives.count(from) > 0) {
        return nullptr; // The action is non-exclusive/already visible elsewhere.
    }
    if (!from->menu() || from->menu()->isEmpty()) {
        return from; // The action is exclusive and doesn't have a menu.
    }
    std::unique_ptr<QAction> menuActionWithExclusives(new QAction(from->icon(), from->text(), parent));
    std::unique_ptr<QMenu> menuWithExclusives(new QMenu(parent));
    const auto fromMenuActions = from->menu()->actions();
    for (QAction *action : fromMenuActions) {
        QAction *actionWithExclusives = actionWithExclusivesFrom(action, menuWithExclusives.get(), nonExclusives);
        if (actionWithExclusives) {
            menuWithExclusives->addAction(actionWithExclusives);
        }
    }
    if (menuWithExclusives->isEmpty()) {
        return nullptr; // "from" has a menu that contains zero exclusive actions.
        // There is a chance that "from" is an exclusive action itself and should
        // therefore be returned instead but that is unlikely for an action that has a menu().
        // This fringe case is the only one that can't be correctly covered because we can
        // not know or assume that activating the action does something or if it is nothing
        // but a container for a menu.
    }
    menuActionWithExclusives->setMenu(menuWithExclusives.release());
    return menuActionWithExclusives.release();
}

std::unique_ptr<QMenu> KHamburgerMenuPrivate::newMenu()
{
    std::unique_ptr<QMenu> menu(new QMenu());
    Q_Q(const KHamburgerMenu);

    // Make sure we notice if the q->menu() is changed or replaced in the future.
    if (q->menu() != m_lastUsedMenu) {
        q->menu()->installEventFilter(m_listeners->get<AddOrRemoveActionListener>());

        if (m_lastUsedMenu && !listContainsWidget(m_widgetsWithActionsToBeHidden, m_lastUsedMenu)) {
            m_lastUsedMenu->removeEventFilter(m_listeners->get<AddOrRemoveActionListener>());
        }
        m_lastUsedMenu = q->menu();
    }

    if (!q->menu() && !m_menuBar) {
        return menu; // empty menu
    }

    if (!q->menu()) {
        // We have nothing else to work with so let's just add the menuBar contents.
        const auto menuBarActions = m_menuBar->actions();
        for (QAction *menuAction : menuBarActions) {
            menu->addAction(menuAction);
        }
        return menu;
    }

    // Collect actions which shouldn't be added to the menu
    std::unordered_set<const QAction *> visibleActions;
    m_widgetsWithActionsToBeHidden.remove(nullptr);
    for (const QWidget *widget : m_widgetsWithActionsToBeHidden) {
        if (qobject_cast<const QMenu *>(widget) || isWidgetActuallyVisible(widget)) {
            // avoid redundancy with menus even when they are not actually visible.
            visibleActions.reserve(visibleActions.size() + widget->actions().size());
            const auto widgetActions = widget->actions();
            for (QAction *action : widgetActions) {
                visibleActions.insert(action);
            }
        }
    }
    // Populate the menu
    const auto menuActions = q->menu()->actions();
    for (QAction *action : menuActions) {
        if (visibleActions.count(action) == 0) {
            menu->addAction(action);
            visibleActions.insert(action);
        }
    }
    // Add the last two menu actions
    if (m_menuBar) {
        connect(menu.get(), &QMenu::aboutToShow, this, [this]() {
            if (m_menuBar->actions().last()->icon().isNull()) {
                m_helpIconIsSet = false;
                m_menuBar->actions().last()->setIcon(QIcon::fromTheme(QStringLiteral("help-contents"))); // set "Help" menu icon
            } else {
                m_helpIconIsSet = true; // if the "Help" icon was set by the application, we want to leave it untouched
            }
        });
        connect(menu.get(), &QMenu::aboutToHide, this, [this]() {
            if (m_menuBar->actions().last()->icon().name() == QStringLiteral("help-contents") && !m_helpIconIsSet) {
                m_menuBar->actions().last()->setIcon(QIcon());
            }
        });
        menu->addAction(m_menuBar->actions().last()); // add "Help" menu
        visibleActions.insert(m_menuBar->actions().last());
        if (m_advertiseMenuBar) {
            menu->addSeparator();
            m_menuBarAdvertisementMenu = newMenuBarAdvertisementMenu(visibleActions);
            menu->addAction(m_menuBarAdvertisementMenu->menuAction());
        }
    }
    return menu;
}

std::unique_ptr<QMenu> KHamburgerMenuPrivate::newMenuBarAdvertisementMenu(std::unordered_set<const QAction *> &visibleActions)
{
    std::unique_ptr<QMenu> advertiseMenuBarMenu(new QMenu());
    m_showMenuBarWithAllActionsText = i18nc("@action:inmenu A menu item that advertises and enables the menubar", "Show &Menubar with All Actions");
    connect(advertiseMenuBarMenu.get(), &QMenu::aboutToShow, this, [this]() {
        if (m_showMenuBarAction) {
            m_showMenuBarText = m_showMenuBarAction->text();
            m_showMenuBarAction->setText(m_showMenuBarWithAllActionsText);
        }
    });
    connect(advertiseMenuBarMenu.get(), &QMenu::aboutToHide, this, [this]() {
        if (m_showMenuBarAction && m_showMenuBarAction->text() == m_showMenuBarWithAllActionsText) {
            m_showMenuBarAction->setText(m_showMenuBarText);
        }
    });
    if (m_showMenuBarAction) {
        advertiseMenuBarMenu->addAction(m_showMenuBarAction);
        visibleActions.insert(m_showMenuBarAction);
    }
    QAction *section = advertiseMenuBarMenu->addSeparator();

    const auto menuBarActions = m_menuBar->actions();
    for (QAction *menuAction : menuBarActions) {
        QAction *menuActionWithExclusives = actionWithExclusivesFrom(menuAction, advertiseMenuBarMenu.get(), visibleActions);
        if (menuActionWithExclusives) {
            advertiseMenuBarMenu->addAction(menuActionWithExclusives);
        }
    }
    advertiseMenuBarMenu->setIcon(QIcon::fromTheme(QStringLiteral("view-more-symbolic")));
    advertiseMenuBarMenu->setTitle(i18nc("@action:inmenu A menu text advertising its contents (more features).", "More"));
    section->setText(i18nc("@action:inmenu A section heading advertising the contents of the menu bar", "More Actions"));
    return advertiseMenuBarMenu;
}

void KHamburgerMenuPrivate::resetMenu()
{
    Q_Q(KHamburgerMenu);
    if (!m_menuResetNeeded && m_actualMenu && m_lastUsedMenu == q->menu()) {
        return;
    }
    m_menuResetNeeded = false;

    m_actualMenu = newMenu();

    const auto createdWidgets = q->createdWidgets();
    for (auto widget : createdWidgets) {
        static_cast<QToolButton *>(widget)->setMenu(m_actualMenu.get());
    }
    if (m_menuAction) {
        m_menuAction->setMenu(m_actualMenu.get());
    }
}

void KHamburgerMenuPrivate::updateVisibility()
{
    Q_Q(KHamburgerMenu);
    /** The visibility of KHamburgerMenu should be opposite to the visibility of m_menuBar.
     * Exception: We only consider a visible m_menuBar as actually visible if it is not a native
     * menu bar because native menu bars can come in many shapes and sizes which don't necessarily
     * have the same usability benefits as a traditional in-window menu bar.
     * KDE applications normally allow the user to remove any actions from their toolbar(s) anyway. */
    const bool menuBarVisible = m_menuBar && (m_menuBar->isVisible() && !m_menuBar->isNativeMenuBar());
    q->setVisible(!menuBarVisible);

    if (!m_menuAction) {
        if (menuBarVisible && m_actualMenu) {
            m_actualMenu.release()->deleteLater(); // might as well free up some memory
        }
        return;
    }

    const auto createdWidgets = q->createdWidgets();
    // The m_menuAction acts as a fallback if both the m_menuBar and all createdWidgets() on the UI
    // are currently hidden. Only then should the m_menuAction ever be visible in a QMenu.
    if (menuBarVisible || (m_menuBar && m_menuBar->isNativeMenuBar()) // See [1] below.
        || std::any_of(createdWidgets.cbegin(), createdWidgets.cend(), isWidgetActuallyVisible)) {
        m_menuAction->setVisible(false);
        return;
    }
    m_menuAction->setVisible(true);

    // [1] While the m_menuAction can be used as a normal menu by users that don't mind invoking a
    //     QMenu to access any menu actions, its primary use really is that of a fallback.
    //     Therefore the existence of a native menu bar (no matter what shape or size it might have)
    //     is enough reason for us to hide m_menuAction.
}

void KHamburgerMenuPrivate::slotActionChanged()
{
    Q_Q(KHamburgerMenu);
    const auto createdWidgets = q->createdWidgets();
    for (auto widget : createdWidgets) {
        auto toolButton = static_cast<QToolButton *>(widget);
        updateButtonStyle(toolButton);
    }
}

void KHamburgerMenuPrivate::updateButtonStyle(QToolButton *hamburgerMenuButton) const
{
    Q_Q(const KHamburgerMenu);
    Qt::ToolButtonStyle buttonStyle = Qt::ToolButtonFollowStyle;
    if (QToolBar *toolbar = qobject_cast<QToolBar *>(hamburgerMenuButton->parent())) {
        buttonStyle = toolbar->toolButtonStyle();
    }
    if (buttonStyle == Qt::ToolButtonFollowStyle) {
        buttonStyle = static_cast<Qt::ToolButtonStyle>(hamburgerMenuButton->style()->styleHint(QStyle::SH_ToolButtonStyle));
    }
    if (buttonStyle == Qt::ToolButtonTextBesideIcon && q->priority() < QAction::NormalPriority) {
        hamburgerMenuButton->setToolButtonStyle(Qt::ToolButtonIconOnly);
    } else {
        hamburgerMenuButton->setToolButtonStyle(buttonStyle);
    }
}
