/*
 * SPDX-FileCopyrightText: 2020 Arjen Hiemstra <ahiemstra@heimr.nl>
 *
 * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
 */

#include "toolbarlayout.h"

#include <cmath>
#include <unordered_map>

#include <QDeadlineTimer>
#include <QElapsedTimer>
#include <QQmlComponent>
#include <QTimer>

#include "loggingcategory.h"
#include "toolbarlayoutdelegate.h"

using namespace Qt::StringLiterals;

ToolBarLayoutAttached::ToolBarLayoutAttached(QObject *parent)
    : QObject(parent)
{
}

QObject *ToolBarLayoutAttached::action() const
{
    return m_action;
}

void ToolBarLayoutAttached::setAction(QObject *action)
{
    m_action = action;
}

class ToolBarLayoutPrivate
{
    ToolBarLayout *const q;

public:
    ToolBarLayoutPrivate(ToolBarLayout *qq)
        : q(qq)
    {
    }
    ~ToolBarLayoutPrivate()
    {
        if (moreButtonIncubator) {
            moreButtonIncubator->clear();
            delete moreButtonIncubator;
        }
    }

    void calculateImplicitSize();
    void performLayout();
    QList<ToolBarLayoutDelegate *> createDelegates();
    ToolBarLayoutDelegate *createDelegate(QObject *action);
    qreal layoutStart(qreal layoutWidth);
    void maybeHideDelegate(int index, qreal &currentWidth, qreal totalWidth);

    QList<QObject *> actions;
    ToolBarLayout::ActionsProperty actionsProperty;
    QList<QObject *> hiddenActions;
    QQmlComponent *fullDelegate = nullptr;
    QQmlComponent *iconDelegate = nullptr;
    QQmlComponent *separatorDelegate = nullptr;
    QQmlComponent *moreButton = nullptr;
    qreal spacing = 0.0;
    Qt::Alignment alignment = Qt::AlignLeft;
    qreal visibleActionsWidth = 0.0;
    qreal visibleWidth = 0.0;
    Qt::LayoutDirection layoutDirection = Qt::LeftToRight;
    ToolBarLayout::HeightMode heightMode = ToolBarLayout::ConstrainIfLarger;

    bool completed = false;
    bool actionsChanged = false;
    bool implicitSizeValid = false;

    std::unordered_map<QObject *, std::unique_ptr<ToolBarLayoutDelegate>> delegates;
    QList<ToolBarLayoutDelegate *> sortedDelegates;
    QQuickItem *moreButtonInstance = nullptr;
    ToolBarDelegateIncubator *moreButtonIncubator = nullptr;
    bool shouldShowMoreButton = false;
    int firstHiddenIndex = -1;

    QList<QObject *> removedActions;
    QTimer *removalTimer = nullptr;

    QElapsedTimer performanceTimer;

    static void appendAction(ToolBarLayout::ActionsProperty *list, QObject *action);
    static qsizetype actionCount(ToolBarLayout::ActionsProperty *list);
    static QObject *action(ToolBarLayout::ActionsProperty *list, qsizetype index);
    static void clearActions(ToolBarLayout::ActionsProperty *list);
};

ToolBarLayout::ToolBarLayout(QQuickItem *parent)
    : QQuickItem(parent)
    , d(std::make_unique<ToolBarLayoutPrivate>(this))
{
    d->actionsProperty = ActionsProperty(this,
                                         this,
                                         ToolBarLayoutPrivate::appendAction,
                                         ToolBarLayoutPrivate::actionCount,
                                         ToolBarLayoutPrivate::action,
                                         ToolBarLayoutPrivate::clearActions);

    // To prevent multiple assignments to actions from constantly recreating
    // delegates, we cache the delegates and only remove them once they are no
    // longer being used. This timer is responsible for triggering that removal.
    d->removalTimer = new QTimer{this};
    d->removalTimer->setInterval(1000);
    d->removalTimer->setSingleShot(true);
    connect(d->removalTimer, &QTimer::timeout, this, [this]() {
        for (auto action : std::as_const(d->removedActions)) {
            if (!d->actions.contains(action)) {
                d->delegates.erase(action);
            }
        }
        d->removedActions.clear();
    });
}

ToolBarLayout::~ToolBarLayout()
{
}

ToolBarLayout::ActionsProperty ToolBarLayout::actionsProperty() const
{
    return d->actionsProperty;
}

void ToolBarLayout::addAction(QObject *action)
{
    if (action == nullptr) {
        return;
    }
    d->actions.append(action);
    d->actionsChanged = true;

    connect(action, &QObject::destroyed, this, [this](QObject *action) {
        auto itr = d->delegates.find(action);
        if (itr != d->delegates.end()) {
            d->delegates.erase(itr);
        }

        d->actions.removeOne(action);
        d->actionsChanged = true;

        relayout();
    });

    relayout();
}

void ToolBarLayout::removeAction(QObject *action)
{
    auto itr = d->delegates.find(action);
    if (itr != d->delegates.end()) {
        itr->second->hide();
    }

    d->actions.removeOne(action);
    d->removedActions.append(action);
    d->removalTimer->start();
    d->actionsChanged = true;

    relayout();
}

void ToolBarLayout::clearActions()
{
    for (auto action : std::as_const(d->actions)) {
        auto itr = d->delegates.find(action);
        if (itr != d->delegates.end()) {
            itr->second->hide();
        }
    }

    d->removedActions.append(d->actions);
    d->actions.clear();
    d->actionsChanged = true;

    relayout();
}

QList<QObject *> ToolBarLayout::hiddenActions() const
{
    return d->hiddenActions;
}

QQmlComponent *ToolBarLayout::fullDelegate() const
{
    return d->fullDelegate;
}

void ToolBarLayout::setFullDelegate(QQmlComponent *newFullDelegate)
{
    if (newFullDelegate == d->fullDelegate) {
        return;
    }

    d->fullDelegate = newFullDelegate;
    d->delegates.clear();
    relayout();
    Q_EMIT fullDelegateChanged();
}

QQmlComponent *ToolBarLayout::iconDelegate() const
{
    return d->iconDelegate;
}

void ToolBarLayout::setIconDelegate(QQmlComponent *newIconDelegate)
{
    if (newIconDelegate == d->iconDelegate) {
        return;
    }

    d->iconDelegate = newIconDelegate;
    d->delegates.clear();
    relayout();
    Q_EMIT iconDelegateChanged();
}

QQmlComponent *ToolBarLayout::separatorDelegate() const
{
    return d->separatorDelegate;
}

void ToolBarLayout::setSeparatorDelegate(QQmlComponent *newSeparatorDelegate)
{
    if (newSeparatorDelegate == d->separatorDelegate) {
        return;
    }

    d->separatorDelegate = newSeparatorDelegate;
    d->delegates.clear();
    relayout();
    Q_EMIT separatorDelegateChanged();
}

QQmlComponent *ToolBarLayout::moreButton() const
{
    return d->moreButton;
}

void ToolBarLayout::setMoreButton(QQmlComponent *newMoreButton)
{
    if (newMoreButton == d->moreButton) {
        return;
    }

    d->moreButton = newMoreButton;
    if (d->moreButtonInstance) {
        d->moreButtonInstance->deleteLater();
        d->moreButtonInstance = nullptr;
    }
    relayout();
    Q_EMIT moreButtonChanged();
}

qreal ToolBarLayout::spacing() const
{
    return d->spacing;
}

void ToolBarLayout::setSpacing(qreal newSpacing)
{
    if (newSpacing == d->spacing) {
        return;
    }

    d->spacing = newSpacing;
    relayout();
    Q_EMIT spacingChanged();
}

Qt::Alignment ToolBarLayout::alignment() const
{
    return d->alignment;
}

void ToolBarLayout::setAlignment(Qt::Alignment newAlignment)
{
    if (newAlignment == d->alignment) {
        return;
    }

    d->alignment = newAlignment;
    relayout();
    Q_EMIT alignmentChanged();
}

qreal ToolBarLayout::visibleWidth() const
{
    return d->visibleWidth;
}

qreal ToolBarLayout::minimumWidth() const
{
    return d->moreButtonInstance ? d->moreButtonInstance->width() : 0;
}

Qt::LayoutDirection ToolBarLayout::layoutDirection() const
{
    return d->layoutDirection;
}

void ToolBarLayout::setLayoutDirection(Qt::LayoutDirection &newLayoutDirection)
{
    if (newLayoutDirection == d->layoutDirection) {
        return;
    }

    d->layoutDirection = newLayoutDirection;
    relayout();
    Q_EMIT layoutDirectionChanged();
}

ToolBarLayout::HeightMode ToolBarLayout::heightMode() const
{
    return d->heightMode;
}

void ToolBarLayout::setHeightMode(HeightMode newHeightMode)
{
    if (newHeightMode == d->heightMode) {
        return;
    }

    d->heightMode = newHeightMode;
    relayout();
    Q_EMIT heightModeChanged();
}

void ToolBarLayout::relayout()
{
    d->implicitSizeValid = false;
    polish();
}

void ToolBarLayout::componentComplete()
{
    QQuickItem::componentComplete();
    d->completed = true;
    d->performLayout();
}

void ToolBarLayout::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry)
{
    if (newGeometry != oldGeometry) {
        relayout();
    }
    QQuickItem::geometryChange(newGeometry, oldGeometry);
}

void ToolBarLayout::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &data)
{
    if (change == ItemSceneChange) {
        relayout();
    } else if (change == ItemVisibleHasChanged) {
        // If we just became visible we want all the actions to be visible and laid out,
        // in order to not draw a frame with an empty toolbar
        for (auto &delegate : std::as_const(d->delegates)) {
            delegate.second->forceCompletion();
        }
        d->implicitSizeValid = false;
        d->performLayout();
    }
    QQuickItem::itemChange(change, data);
}

void ToolBarLayout::updatePolish()
{
    d->performLayout();
}

/*
 * Calculate the implicit size for this layout.
 *
 * This is a separate step from performing the actual layout, because of a nasty
 * little issue with Control, where it will unconditionally set the height of
 * its contentItem, which means QQuickItem::heightValid() becomes useless. So
 * instead, we first calculate our implicit size, ignoring any explicitly set
 * item size. Then we follow that by performing the actual layouting, using the
 * width and height retrieved from the item, as those will return the explicitly
 * set width/height if set and the implicit size otherwise. Since control
 * watches for implicit size changes, we end up with correct behaviour both when
 * we get an explicit size set and when we're relying on implicit size
 * calculation.
 */
void ToolBarLayoutPrivate::calculateImplicitSize()
{
    if (!completed) {
        return;
    }

    if (!fullDelegate || !iconDelegate || !separatorDelegate || !moreButton) {
        qCWarning(KirigamiLayoutsLog) << "ToolBarLayout: Unable to layout, required properties are not set";
        return;
    }

    if (actions.isEmpty()) {
        q->setImplicitSize(0., 0.);
        return;
    }

    hiddenActions.clear();
    firstHiddenIndex = -1;

    sortedDelegates = createDelegates();

    bool ready = std::all_of(delegates.cbegin(), delegates.cend(), [](const std::pair<QObject *const, std::unique_ptr<ToolBarLayoutDelegate>> &entry) {
        // If the delegate wants to always be hidden we don't care if it's still loading
        return entry.second->isReady() || entry.second->isHidden();
    });
    if (!ready || !moreButtonInstance) {
        return;
    }

    qreal maxHeight = 0.0;
    qreal maxWidth = 0.0;

    // First, calculate the total width and maximum height of all delegates.
    // This will be used to determine which actions to show, which ones to
    // collapse to icon-only etc.
    for (auto entry : std::as_const(sortedDelegates)) {
        if (!entry->isActionVisible()) {
            entry->hide();
            continue;
        }

        if (entry->isHidden()) {
            entry->hide();
            hiddenActions.append(entry->action());
            continue;
        }

        if (entry->isIconOnly()) {
            entry->showIcon();
        } else {
            entry->showFull();
        }

        maxWidth += entry->width() + spacing;
        maxHeight = std::max(maxHeight, entry->maxHeight());
    }

    // The last entry also gets spacing but shouldn't, so remove that.
    maxWidth -= spacing;

    visibleActionsWidth = 0.0;

    q->setImplicitWidth(maxWidth);

    if (maxWidth > q->width() - (hiddenActions.isEmpty() ? 0.0 : moreButtonInstance->width() + spacing)) {
        // We have more items than fit into the view, so start hiding some.

        qreal layoutWidth = q->width() - (moreButtonInstance->width() + spacing);
        if (alignment & Qt::AlignHCenter) {
            // When centering, we need to reserve space on both sides to make sure
            // things are properly centered, otherwise we will be to the right of
            // the center.
            layoutWidth -= (moreButtonInstance->width() + spacing);
        }

        for (int i = 0; i < sortedDelegates.size(); ++i) {
            auto delegate = sortedDelegates.at(i);

            maybeHideDelegate(i, visibleActionsWidth, layoutWidth);

            if (delegate->isVisible()) {
                visibleActionsWidth += delegate->width() + spacing;
            }
        }
        if (!qFuzzyIsNull(visibleActionsWidth)) {
            // Like above, remove spacing on the last element that incorrectly gets spacing added.
            visibleActionsWidth -= spacing;
        }
    } else {
        visibleActionsWidth = maxWidth;
    }

    if (!hiddenActions.isEmpty()) {
        maxHeight = std::max(maxHeight, moreButtonInstance->implicitHeight());
    };

    q->setImplicitHeight(maxHeight);

    Q_EMIT q->hiddenActionsChanged();

    implicitSizeValid = true;

    q->polish();
}

void ToolBarLayoutPrivate::performLayout()
{
    if (!completed || actions.isEmpty()) {
        return;
    }

    if (!implicitSizeValid) {
        calculateImplicitSize();
    }

    if (sortedDelegates.isEmpty() || !moreButtonInstance) {
        sortedDelegates = createDelegates();
    }

    bool ready = std::all_of(delegates.cbegin(), delegates.cend(), [](const std::pair<QObject *const, std::unique_ptr<ToolBarLayoutDelegate>> &entry) {
        // If the delegate wants to always be hidden we don't care if it's still loading
        return entry.second->isReady() || entry.second->isHidden();
    });
    if (!ready) {
        // Don't continue to layout if all delegates aren't ready yet
        return;
    }

    qreal width = q->width();
    qreal height = q->height();

    if (!hiddenActions.isEmpty()) {
        if (layoutDirection == Qt::LeftToRight) {
            moreButtonInstance->setX(width - moreButtonInstance->width());
        } else {
            moreButtonInstance->setX(0.0);
        }

        if (heightMode == ToolBarLayout::AlwaysFill) {
            moreButtonInstance->setHeight(height);
        } else if (heightMode == ToolBarLayout::ConstrainIfLarger) {
            if (moreButtonInstance->implicitHeight() > height) {
                moreButtonInstance->setHeight(height);
            } else {
                moreButtonInstance->resetHeight();
            }
        } else {
            moreButtonInstance->resetHeight();
        }

        moreButtonInstance->setY(qRound((height - moreButtonInstance->height()) / 2.0));
        shouldShowMoreButton = true;
        moreButtonInstance->setVisible(true);
    } else {
        shouldShowMoreButton = false;
        moreButtonInstance->setVisible(false);
    }

    qreal currentX = layoutStart(visibleActionsWidth);
    for (auto entry : std::as_const(sortedDelegates)) {
        if (!entry->isVisible()) {
            continue;
        }

        if (heightMode == ToolBarLayout::AlwaysFill) {
            entry->setHeight(height);
        } else if (heightMode == ToolBarLayout::ConstrainIfLarger) {
            if (entry->implicitHeight() > height) {
                entry->setHeight(height);
            } else {
                entry->resetHeight();
            }
        } else {
            entry->resetHeight();
        }

        qreal y = qRound((height - entry->height()) / 2.0);

        if (layoutDirection == Qt::LeftToRight) {
            entry->setPosition(currentX, y);
            currentX += entry->width() + spacing;
        } else {
            entry->setPosition(currentX - entry->width(), y);
            currentX -= entry->width() + spacing;
        }

        entry->show();
    }

    qreal newVisibleWidth = visibleActionsWidth;
    if (moreButtonInstance->isVisible()) {
        newVisibleWidth += moreButtonInstance->width() + (newVisibleWidth > 0.0 ? spacing : 0.0);
    }
    if (!qFuzzyCompare(newVisibleWidth, visibleWidth)) {
        visibleWidth = newVisibleWidth;
        Q_EMIT q->visibleWidthChanged();
    }

    if (actionsChanged) {
        // Due to the way QQmlListProperty works, if we emit changed every time
        // an action is added/removed, we end up emitting way too often. So
        // instead only do it after everything else is done.
        Q_EMIT q->actionsChanged();
        actionsChanged = false;
    }

    sortedDelegates.clear();
}

QList<ToolBarLayoutDelegate *> ToolBarLayoutPrivate::createDelegates()
{
    QList<ToolBarLayoutDelegate *> result;
    for (auto action : std::as_const(actions)) {
        if (delegates.find(action) != delegates.end()) {
            result.append(delegates.at(action).get());
        } else if (action) {
            auto delegate = std::unique_ptr<ToolBarLayoutDelegate>(createDelegate(action));
            if (delegate) {
                result.append(delegate.get());
                delegates.emplace(action, std::move(delegate));
            }
        }
    }

    if (!moreButtonInstance && moreButton) {
        moreButtonInstance = qobject_cast<QQuickItem *>(
            moreButton->createWithInitialProperties({{u"parent"_s, QVariant::fromValue(q)}, {u"visible"_s, false}}, qmlContext(moreButton)));

        if (moreButtonInstance) {
            moreButtonInstance->setParent(q);
            QObject::connect(moreButtonInstance, &QQuickItem::visibleChanged, q, [this]() {
                moreButtonInstance->setVisible(shouldShowMoreButton);
            });
            QObject::connect(moreButtonInstance, &QQuickItem::widthChanged, q, &ToolBarLayout::minimumWidthChanged);
            q->relayout();
            Q_EMIT q->minimumWidthChanged();
        }
    }

    return result;
}

ToolBarLayoutDelegate *ToolBarLayoutPrivate::createDelegate(QObject *action)
{
    QQmlComponent *fullComponent = nullptr;
    auto displayComponent = action->property("displayComponent");
    if (displayComponent.isValid()) {
        fullComponent = displayComponent.value<QQmlComponent *>();
    }

    if (!fullComponent) {
        fullComponent = fullDelegate;
    }

    auto separator = action->property("separator");
    if (separator.isValid() && separator.toBool()) {
        fullComponent = separatorDelegate;
    }

    auto result = new ToolBarLayoutDelegate(q);
    result->setAction(action);
    result->createItems(fullComponent, iconDelegate, [this, action](QQuickItem *newItem) {
        newItem->setParentItem(q);
        newItem->setParent(q);
        auto attached = static_cast<ToolBarLayoutAttached *>(qmlAttachedPropertiesObject<ToolBarLayout>(newItem, true));
        attached->setAction(action);

        if (!q->childItems().isEmpty() && q->childItems().first() != newItem) {
            // Due to asynchronous item creation, we end up creating the last item
            // first. So move items before previously inserted items to ensure
            // we have a more sensible tab order.
            // Note that this will be incorrect if we end up completing in random
            // order.
            newItem->stackBefore(q->childItems().first());
        }
    });

    return result;
}

qreal ToolBarLayoutPrivate::layoutStart(qreal layoutWidth)
{
    qreal availableWidth = moreButtonInstance->isVisible() ? q->width() - (moreButtonInstance->width() + spacing) : q->width();

    if (alignment & Qt::AlignLeft) {
        return layoutDirection == Qt::LeftToRight ? 0.0 : q->width();
    } else if (alignment & Qt::AlignHCenter) {
        return (q->width() / 2) + (layoutDirection == Qt::LeftToRight ? -layoutWidth / 2.0 : layoutWidth / 2.0);
    } else if (alignment & Qt::AlignRight) {
        qreal offset = availableWidth - layoutWidth;
        return layoutDirection == Qt::LeftToRight ? offset : q->width() - offset;
    }
    return 0.0;
}

void ToolBarLayoutPrivate::maybeHideDelegate(int index, qreal &currentWidth, qreal totalWidth)
{
    auto delegate = sortedDelegates.at(index);

    if (!delegate->isVisible()) {
        // If the delegate isn't visible anyway, do nothing.
        return;
    }

    if (currentWidth + delegate->width() < totalWidth && (firstHiddenIndex < 0 || index < firstHiddenIndex)) {
        // If the delegate is fully visible and we have not already hidden
        // actions, do nothing.
        return;
    }

    if (delegate->isKeepVisible()) {
        // If the action is marked as KeepVisible, we need to try our best to
        // keep it in view. If the full size delegate does not fit, we try the
        // icon-only delegate. If that also does not fit, try and find other
        // actions to hide. Finally, if that also fails, we will hide the
        // delegate.
        if (currentWidth + delegate->iconWidth() > totalWidth) {
            // First, hide any earlier actions that are not marked as KeepVisible.
            for (auto currentIndex = index - 1; currentIndex >= 0; --currentIndex) {
                auto previousDelegate = sortedDelegates.at(currentIndex);
                if (!previousDelegate->isVisible() || previousDelegate->isKeepVisible()) {
                    continue;
                }

                auto width = previousDelegate->width();
                previousDelegate->hide();
                hiddenActions.append(previousDelegate->action());
                currentWidth -= (width + spacing);

                if (currentWidth + delegate->fullWidth() <= totalWidth) {
                    delegate->showFull();
                    break;
                } else if (currentWidth + delegate->iconWidth() <= totalWidth) {
                    delegate->showIcon();
                    break;
                }
            }

            if (currentWidth + delegate->width() <= totalWidth) {
                return;
            }

            // Hiding normal actions did not help enough, so go through actions
            // with KeepVisible set and try and collapse them to IconOnly.
            for (auto currentIndex = index - 1; currentIndex >= 0; --currentIndex) {
                auto previousDelegate = sortedDelegates.at(currentIndex);
                if (!previousDelegate->isVisible() || !previousDelegate->isKeepVisible()) {
                    continue;
                }

                auto extraSpace = previousDelegate->width() - previousDelegate->iconWidth();
                previousDelegate->showIcon();
                currentWidth -= extraSpace;

                if (currentWidth + delegate->fullWidth() <= totalWidth) {
                    delegate->showFull();
                    break;
                } else if (currentWidth + delegate->iconWidth() <= totalWidth) {
                    delegate->showIcon();
                    break;
                }
            }

            // If that also did not work, then hide this action after all.
            if (currentWidth + delegate->width() > totalWidth) {
                delegate->hide();
                hiddenActions.append(delegate->action());
            }
        } else {
            delegate->showIcon();
        }
    } else {
        // The action is not marked as KeepVisible and it does not fit within
        // the current layout, so hide it.
        delegate->hide();
        hiddenActions.append(delegate->action());

        // If this is the first item to be hidden, mark it so we know we should
        // also hide the following items.
        if (firstHiddenIndex < 0) {
            firstHiddenIndex = index;
        }
    }
}

void ToolBarLayoutPrivate::appendAction(ToolBarLayout::ActionsProperty *list, QObject *action)
{
    auto layout = reinterpret_cast<ToolBarLayout *>(list->data);
    layout->addAction(action);
}

qsizetype ToolBarLayoutPrivate::actionCount(ToolBarLayout::ActionsProperty *list)
{
    return reinterpret_cast<ToolBarLayout *>(list->data)->d->actions.count();
}

QObject *ToolBarLayoutPrivate::action(ToolBarLayout::ActionsProperty *list, qsizetype index)
{
    return reinterpret_cast<ToolBarLayout *>(list->data)->d->actions.at(index);
}

void ToolBarLayoutPrivate::clearActions(ToolBarLayout::ActionsProperty *list)
{
    reinterpret_cast<ToolBarLayout *>(list->data)->clearActions();
}

#include "moc_toolbarlayout.cpp"
