/*
    SPDX-FileCopyrightText: 2016 Eike Hein <hein@kde.org>
    SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>

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

#include "notificationgroupingproxymodel_p.h"

#include <QDateTime>

#include "notifications.h"

using namespace NotificationManager;

NotificationGroupingProxyModel::NotificationGroupingProxyModel(QObject *parent)
    : QAbstractProxyModel(parent)
{
}

NotificationGroupingProxyModel::~NotificationGroupingProxyModel() = default;

bool NotificationGroupingProxyModel::appsMatch(const QModelIndex &a, const QModelIndex &b) const
{
    const QString aName = a.data(Notifications::ApplicationNameRole).toString();
    const QString bName = b.data(Notifications::ApplicationNameRole).toString();

    const QString aDesktopEntry = a.data(Notifications::DesktopEntryRole).toString();
    const QString bDesktopEntry = b.data(Notifications::DesktopEntryRole).toString();

    const QString aOriginName = a.data(Notifications::OriginNameRole).toString();
    const QString bOriginName = b.data(Notifications::OriginNameRole).toString();

    return !aName.isEmpty() && aName == bName && aDesktopEntry == bDesktopEntry && aOriginName == bOriginName;
}

bool NotificationGroupingProxyModel::isGroup(int row) const
{
    if (row < 0 || row >= rowMap.count()) {
        return false;
    }

    return (rowMap.at(row)->count() > 1);
}

bool NotificationGroupingProxyModel::tryToGroup(const QModelIndex &sourceIndex, bool silent)
{
    // Meat of the matter: Try to add this source row to a sub-list with source rows
    // associated with the same application.
    for (int i = 0; i < rowMap.count(); ++i) {
        const QModelIndex &groupRep = sourceModel()->index(rowMap.at(i)->constFirst(), 0);

        // Don't match a row with itself.
        if (sourceIndex == groupRep) {
            continue;
        }

        if (appsMatch(sourceIndex, groupRep)) {
            const QModelIndex parent = index(i, 0);

            if (!silent) {
                const int newIndex = rowMap.at(i)->count();

                if (newIndex == 1) {
                    beginInsertRows(parent, 0, 1);
                } else {
                    beginInsertRows(parent, newIndex, newIndex);
                }
            }

            rowMap[i]->append(sourceIndex.row());

            if (!silent) {
                endInsertRows();

                Q_EMIT dataChanged(parent, parent);
            }

            return true;
        }
    }

    return false;
}

void NotificationGroupingProxyModel::adjustMap(int anchor, int delta)
{
    for (int i = 0; i < rowMap.count(); ++i) {
        QVector<int> *sourceRows = rowMap.at(i);
        QMutableVectorIterator<int> it(*sourceRows);

        while (it.hasNext()) {
            it.next();

            if (it.value() >= anchor) {
                it.setValue(it.value() + delta);
            }
        }
    }
}

void NotificationGroupingProxyModel::rebuildMap()
{
    qDeleteAll(rowMap);
    rowMap.clear();

    const int rows = sourceModel()->rowCount();

    rowMap.reserve(rows);

    for (int i = 0; i < rows; ++i) {
        rowMap.append(new QVector<int>{i});
    }

    checkGrouping(true /* silent */);
}

void NotificationGroupingProxyModel::checkGrouping(bool silent)
{
    for (int i = (rowMap.count()) - 1; i >= 0; --i) {
        if (isGroup(i)) {
            continue;
        }

        // FIXME support skip grouping hint, maybe?
        // The new grouping keeps every notification separate, still, so perhaps we don't need to

        if (tryToGroup(sourceModel()->index(rowMap.at(i)->constFirst(), 0), silent)) {
            beginRemoveRows(QModelIndex(), i, i);
            delete rowMap.takeAt(i); // Safe since we're iterating backwards.
            endRemoveRows();
        }
    }
}

void NotificationGroupingProxyModel::formGroupFor(const QModelIndex &index)
{
    // Already in group or a group.
    if (index.parent().isValid() || isGroup(index.row())) {
        return;
    }

    // We need to grab a source index as we may invalidate the index passed
    // in through grouping.
    const QModelIndex &sourceTarget = mapToSource(index);

    for (int i = (rowMap.count() - 1); i >= 0; --i) {
        const QModelIndex &sourceIndex = sourceModel()->index(rowMap.at(i)->constFirst(), 0);

        if (!appsMatch(sourceTarget, sourceIndex)) {
            continue;
        }

        if (tryToGroup(sourceIndex)) {
            beginRemoveRows(QModelIndex(), i, i);
            delete rowMap.takeAt(i); // Safe since we're iterating backwards.
            endRemoveRows();
        }
    }
}

void NotificationGroupingProxyModel::setSourceModel(QAbstractItemModel *sourceModel)
{
    if (sourceModel == QAbstractProxyModel::sourceModel()) {
        return;
    }

    beginResetModel();

    if (QAbstractProxyModel::sourceModel()) {
        QAbstractProxyModel::sourceModel()->disconnect(this);
    }

    QAbstractProxyModel::setSourceModel(sourceModel);

    if (sourceModel) {
        rebuildMap();

        // FIXME move this stuff into separate slot methods

        connect(sourceModel, &QAbstractItemModel::rowsInserted, this, [this](const QModelIndex &parent, int start, int end) {
            if (parent.isValid()) {
                return;
            }

            adjustMap(start, (end - start) + 1);

            for (int i = start; i <= end; ++i) {
                if (!tryToGroup(this->sourceModel()->index(i, 0))) {
                    beginInsertRows(QModelIndex(), rowMap.count(), rowMap.count());
                    rowMap.append(new QVector<int>{i});
                    endInsertRows();
                }
            }

            checkGrouping();
        });

        connect(sourceModel, &QAbstractItemModel::rowsAboutToBeRemoved, this, [this](const QModelIndex &parent, int first, int last) {
            if (parent.isValid()) {
                return;
            }

            for (int i = first; i <= last; ++i) {
                for (int j = 0; j < rowMap.count(); ++j) {
                    const QVector<int> *sourceRows = rowMap.at(j);
                    const int mapIndex = sourceRows->indexOf(i);

                    if (mapIndex != -1) {
                        // Remove top-level item.
                        if (sourceRows->count() == 1) {
                            beginRemoveRows(QModelIndex(), j, j);
                            delete rowMap.takeAt(j);
                            endRemoveRows();
                            // Dissolve group.
                        } else if (sourceRows->count() == 2) {
                            const QModelIndex parent = index(j, 0);
                            beginRemoveRows(parent, 0, 1);
                            rowMap[j]->remove(mapIndex);
                            endRemoveRows();

                            // We're no longer a group parent.
                            Q_EMIT dataChanged(parent, parent);
                            // Remove group member.
                        } else {
                            const QModelIndex parent = index(j, 0);
                            beginRemoveRows(parent, mapIndex, mapIndex);
                            rowMap[j]->remove(mapIndex);
                            endRemoveRows();

                            // Various roles of the parent evaluate child data, and the
                            // child list has changed.
                            Q_EMIT dataChanged(parent, parent);

                            // Signal children count change for all other items in the group.
                            Q_EMIT dataChanged(index(0, 0, parent), index(rowMap.count() - 1, 0, parent), {Notifications::GroupChildrenCountRole});
                        }

                        break;
                    }
                }
            }
        });

        connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, [this](const QModelIndex &parent, int start, int end) {
            if (parent.isValid()) {
                return;
            }

            adjustMap(start + 1, -((end - start) + 1));

            checkGrouping();
        });

        connect(sourceModel, &QAbstractItemModel::modelAboutToBeReset, this, &NotificationGroupingProxyModel::beginResetModel);
        connect(sourceModel, &QAbstractItemModel::modelReset, this, [this] {
            rebuildMap();
            endResetModel();
        });

        connect(sourceModel,
                &QAbstractItemModel::dataChanged,
                this,
                [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles) {
                    for (int i = topLeft.row(); i <= bottomRight.row(); ++i) {
                        const QModelIndex &sourceIndex = this->sourceModel()->index(i, 0);
                        QModelIndex proxyIndex = mapFromSource(sourceIndex);

                        if (!proxyIndex.isValid()) {
                            return;
                        }

                        const QModelIndex parent = proxyIndex.parent();

                        // If a child item changes, its parent may need an update as well as many of
                        // the data roles evaluate child data. See data().
                        // TODO: Some roles do not need to bubble up as they fall through to the first
                        // child in data(); it _might_ be worth adding constraints here later.
                        if (parent.isValid()) {
                            Q_EMIT dataChanged(parent, parent, roles);
                        }

                        Q_EMIT dataChanged(proxyIndex, proxyIndex, roles);
                    }
                });
    }

    endResetModel();
}

QModelIndex NotificationGroupingProxyModel::index(int row, int column, const QModelIndex &parent) const
{
    if (row < 0 || column != 0) {
        return QModelIndex();
    }

    if (parent.isValid() && row < rowMap.at(parent.row())->count()) {
        return createIndex(row, column, rowMap.at(parent.row()));
    }

    if (row < rowMap.count()) {
        return createIndex(row, column, nullptr);
    }

    return QModelIndex();
}

QModelIndex NotificationGroupingProxyModel::parent(const QModelIndex &child) const
{
    if (child.internalPointer() == nullptr) {
        return QModelIndex();
    } else {
        const int parentRow = rowMap.indexOf(static_cast<QVector<int> *>(child.internalPointer()));

        if (parentRow != -1) {
            return index(parentRow, 0);
        }

        // If we were asked to find the parent for an internalPointer we can't
        // locate, we have corrupted data: This should not happen.
        Q_ASSERT(parentRow != -1);
    }

    return QModelIndex();
}

QModelIndex NotificationGroupingProxyModel::mapFromSource(const QModelIndex &sourceIndex) const
{
    if (!sourceIndex.isValid() || sourceIndex.model() != sourceModel()) {
        return QModelIndex();
    }

    for (int i = 0; i < rowMap.count(); ++i) {
        const QVector<int> *sourceRows = rowMap.at(i);
        const int childIndex = sourceRows->indexOf(sourceIndex.row());
        const QModelIndex parent = index(i, 0);

        if (childIndex == 0) {
            // If the sub-list we found the source row in is larger than 1 (i.e. part
            // of a group, map to the logical child item instead of the parent item
            // the source row also stands in for. The parent is therefore unreachable
            // from mapToSource().
            if (isGroup(i)) {
                return index(0, 0, parent);
                // Otherwise map to the top-level item.
            } else {
                return parent;
            }
        } else if (childIndex != -1) {
            return index(childIndex, 0, parent);
        }
    }

    return QModelIndex();
}

QModelIndex NotificationGroupingProxyModel::mapToSource(const QModelIndex &proxyIndex) const
{
    if (!proxyIndex.isValid() || proxyIndex.model() != this || !sourceModel()) {
        return QModelIndex();
    }

    const QModelIndex &parent = proxyIndex.parent();

    if (parent.isValid()) {
        if (parent.row() < 0 || parent.row() >= rowMap.count()) {
            return QModelIndex();
        }

        return sourceModel()->index(rowMap.at(parent.row())->at(proxyIndex.row()), 0);
    } else {
        // Group parents items therefore equate to the first child item; the source
        // row logically appears twice in the proxy.
        // mapFromSource() is not required to handle this well (consider proxies can
        // filter out rows, too) and opts to map to the child item, as the group parent
        // has its Qt::DisplayRole mangled by data(), and it's more useful for trans-
        // lating dataChanged() from the source model.
        // NOTE we changed that to be last
        if (rowMap.isEmpty()) { // FIXME
            // How can this happen? (happens when closing a group)
            return QModelIndex();
        }
        return sourceModel()->index(rowMap.at(proxyIndex.row())->constLast(), 0);
    }

    return QModelIndex();
}

int NotificationGroupingProxyModel::rowCount(const QModelIndex &parent) const
{
    if (!sourceModel()) {
        return 0;
    }

    if (parent.isValid() && parent.model() == this) {
        // Don't return row count for top-level item at child row: Group members
        // never have further children of their own.
        if (parent.parent().isValid()) {
            return 0;
        }

        if (parent.row() < 0 || parent.row() >= rowMap.count()) {
            return 0;
        }

        const int rowCount = rowMap.at(parent.row())->count();

        // If this sub-list in the map only has one entry, it's a plain item, not
        // parent to a group.
        if (rowCount == 1) {
            return 0;
        } else {
            return rowCount;
        }
    }

    return rowMap.count();
}

bool NotificationGroupingProxyModel::hasChildren(const QModelIndex &parent) const
{
    if ((parent.model() && parent.model() != this) || !sourceModel()) {
        return false;
    }

    return rowCount(parent);
}

int NotificationGroupingProxyModel::columnCount(const QModelIndex &parent) const
{
    Q_UNUSED(parent)

    return 1;
}

QVariant NotificationGroupingProxyModel::data(const QModelIndex &proxyIndex, int role) const
{
    if (!proxyIndex.isValid() || proxyIndex.model() != this || !sourceModel()) {
        return QVariant();
    }

    const QModelIndex &parent = proxyIndex.parent();
    const bool isGroup = (!parent.isValid() && this->isGroup(proxyIndex.row()));

    // For group parent items, this will map to the last child task.
    const QModelIndex &sourceIndex = mapToSource(proxyIndex);

    if (!sourceIndex.isValid()) {
        return QVariant();
    }

    if (isGroup) {
        switch (role) {
        case Notifications::IsGroupRole:
            return true;
        case Notifications::GroupChildrenCountRole:
            return rowCount(proxyIndex);
        case Notifications::IsInGroupRole:
            return false;

        // Combine all notifications into one for some basic grouping
        case Notifications::BodyRole: {
            QString body;
            for (int i = 0; i < rowCount(proxyIndex); ++i) {
                const QString stringData = index(i, 0, proxyIndex).data(role).toString();
                if (!stringData.isEmpty()) {
                    if (!body.isEmpty()) {
                        body.append(QLatin1String("<br>"));
                    }
                    body.append(stringData);
                }
            }
            return body;
        }

        case Notifications::DesktopEntryRole:
        case Notifications::NotifyRcNameRole:
        case Notifications::OriginNameRole:
            for (int i = 0; i < rowCount(proxyIndex); ++i) {
                const QString stringData = index(i, 0, proxyIndex).data(role).toString();
                if (!stringData.isEmpty()) {
                    return stringData;
                }
            }
            return QString();

        case Notifications::ConfigurableRole: // if there is any configurable child item
            for (int i = 0; i < rowCount(proxyIndex); ++i) {
                if (index(i, 0, proxyIndex).data(Notifications::ConfigurableRole).toBool()) {
                    return true;
                }
            }
            return false;

        case Notifications::ClosableRole: // if there is any closable child item
            for (int i = 0; i < rowCount(proxyIndex); ++i) {
                if (index(i, 0, proxyIndex).data(Notifications::ClosableRole).toBool()) {
                    return true;
                }
            }
            return false;
        }
    } else {
        switch (role) {
        case Notifications::IsGroupRole:
            return false;
        // So a notification knows with how many other items it is in a group
        case Notifications::GroupChildrenCountRole:
            if (proxyIndex.parent().isValid()) {
                return rowCount(proxyIndex.parent());
            }
            break;
        case Notifications::IsInGroupRole:
            return parent.isValid();
        }
    }

    return sourceIndex.data(role);
}
