/*
    SPDX-FileCopyrightText: 2016 Eike Hein <hein@kde.org>

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

#include "tasksmodel.h"
#include "activityinfo.h"
#include "concatenatetasksproxymodel.h"
#include "flattentaskgroupsproxymodel.h"
#include "taskfilterproxymodel.h"
#include "taskgroupingproxymodel.h"
#include "tasktools.h"
#include "virtualdesktopinfo.h"

#include "launchertasksmodel.h"
#include "startuptasksmodel.h"
#include "windowtasksmodel.h"

#include "launchertasksmodel_p.h"

#include <QDateTime>
#include <QGuiApplication>
#include <QList>
#include <QTimer>
#include <QUrl>

#include <algorithm>
#include <numeric>
#include <optional>

namespace TaskManager
{
class Q_DECL_HIDDEN TasksModel::Private
{
public:
    Private(TasksModel *q);
    ~Private();

    static int instanceCount;

    static WindowTasksModel *windowTasksModel;
    static StartupTasksModel *startupTasksModel;
    LauncherTasksModel *launcherTasksModel = nullptr;
    ConcatenateTasksProxyModel *concatProxyModel = nullptr;
    TaskFilterProxyModel *filterProxyModel = nullptr;
    TaskGroupingProxyModel *groupingProxyModel = nullptr;
    FlattenTaskGroupsProxyModel *flattenGroupsProxyModel = nullptr;
    AbstractTasksModelIface *abstractTasksSourceModel = nullptr;

    bool anyTaskDemandsAttention = false;

    int launcherCount = 0;

    SortMode sortMode = SortAlpha;
    bool separateLaunchers = true;
    bool launchInPlace = false;
    bool hideActivatedLaunchers = true;
    bool launchersEverSet = false;
    bool launcherSortingDirty = false;
    bool launcherCheckNeeded = false;
    QList<int> sortedPreFilterRows;
    QList<int> sortRowInsertQueue;
    bool sortRowInsertQueueStale = false;
    std::shared_ptr<VirtualDesktopInfo> virtualDesktopInfo;
    QHash<QString, int> activityTaskCounts;
    std::shared_ptr<ActivityInfo> activityInfo;

    bool groupInline = false;
    int groupingWindowTasksThreshold = -1;

    bool usedByQml = false;
    bool componentComplete = false;

    void initModels();
    void initLauncherTasksModel();
    void updateAnyTaskDemandsAttention();
    void updateManualSortMap();
    void consolidateManualSortMapForGroup(const QModelIndex &groupingProxyIndex);
    void updateGroupInline();
    QModelIndex preFilterIndex(const QModelIndex &sourceIndex) const;
    void updateActivityTaskCounts();
    void forceResort();
    bool lessThan(const QModelIndex &left, const QModelIndex &right, bool sortOnlyLaunchers = false) const;
    std::optional<bool> lessThanByVirtualDesktop(const QModelIndex &left, const QModelIndex &right) const;

private:
    TasksModel *const q;
};

class TasksModel::TasksModelLessThan
{
public:
    inline TasksModelLessThan(const QAbstractItemModel *s, TasksModel *p, bool sortOnlyLaunchers)
        : sourceModel(s)
        , tasksModel(p)
        , sortOnlyLaunchers(sortOnlyLaunchers)
    {
    }

    inline bool operator()(int r1, int r2) const
    {
        QModelIndex i1 = sourceModel->index(r1, 0);
        QModelIndex i2 = sourceModel->index(r2, 0);
        return tasksModel->d->lessThan(i1, i2, sortOnlyLaunchers);
    }

private:
    const QAbstractItemModel *sourceModel;
    const TasksModel *tasksModel;
    bool sortOnlyLaunchers;
};

int TasksModel::Private::instanceCount = 0;
WindowTasksModel *TasksModel::Private::windowTasksModel = nullptr;
StartupTasksModel *TasksModel::Private::startupTasksModel = nullptr;

TasksModel::Private::Private(TasksModel *q)
    : q(q)
{
    ++instanceCount;
}

TasksModel::Private::~Private()
{
    --instanceCount;

    if (!instanceCount) {
        delete std::exchange(windowTasksModel, nullptr);
        delete std::exchange(startupTasksModel, nullptr);
    }
}

void TasksModel::Private::initModels()
{
    // NOTE: Overview over the entire model chain assembled here:
    // WindowTasksModel, StartupTasksModel, LauncherTasksModel
    //  -> concatProxyModel concatenates them into a single list.
    //   -> filterProxyModel filters by state (e.g. virtual desktop).
    //    -> groupingProxyModel groups by application (we go from flat list to tree).
    //     -> flattenGroupsProxyModel (optionally, if groupInline == true) flattens groups out.
    //      -> TasksModel collapses (top-level) items into task lifecycle abstraction; sorts.

    concatProxyModel = new ConcatenateTasksProxyModel(q);

    if (!windowTasksModel) {
        windowTasksModel = new WindowTasksModel();
    }

    concatProxyModel->addSourceModel(windowTasksModel);

    QObject::connect(windowTasksModel, &QAbstractItemModel::rowsInserted, q, [this]() {
        if (sortMode == SortActivity) {
            updateActivityTaskCounts();
        }
    });

    QObject::connect(windowTasksModel, &QAbstractItemModel::rowsRemoved, q, [this]() {
        if (sortMode == SortActivity) {
            updateActivityTaskCounts();
            forceResort();
        }
        // the active task may have potentially changed, so signal that so that users
        // will recompute it
        Q_EMIT q->activeTaskChanged();
    });

    QObject::connect(windowTasksModel,
                     &QAbstractItemModel::dataChanged,
                     q,
                     [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList<int> &roles) {
                         Q_UNUSED(topLeft)
                         Q_UNUSED(bottomRight)

                         if (sortMode == SortActivity && roles.contains(AbstractTasksModel::Activities)) {
                             updateActivityTaskCounts();
                         }

                         if (roles.contains(AbstractTasksModel::IsActive)) {
                             Q_EMIT q->activeTaskChanged();
                         }

                         // In manual sort mode, updateManualSortMap() may consult the sortRowInsertQueue
                         // for new tasks to sort in. Hidden tasks remain in the queue to potentially sort
                         // them later, when they are are actually revealed to the user.
                         // This is particularly useful in concert with taskmanagerrulesrc's SkipTaskbar
                         // key, which is used to hide window tasks which update from bogus to useful
                         // window metadata early in startup. The role change then coincides with positive
                         // app identification, which is when updateManualSortMap() becomes able to sort the
                         // task adjacent to its launcher when required to do so.
                         if (sortMode == SortManual && roles.contains(AbstractTasksModel::SkipTaskbar)) {
                             updateManualSortMap();
                         }
                     });

    if (!startupTasksModel) {
        startupTasksModel = new StartupTasksModel();
    }

    concatProxyModel->addSourceModel(startupTasksModel);

    // If we're in manual sort mode, we need to seed the sort map on pending row
    // insertions.
    QObject::connect(concatProxyModel, &QAbstractItemModel::rowsAboutToBeInserted, q, [this](const QModelIndex &parent, int start, int end) {
        Q_UNUSED(parent)

        if (sortMode != SortManual) {
            return;
        }

        const int delta = (end - start) + 1;
        for (auto it = sortedPreFilterRows.begin(); it != sortedPreFilterRows.end(); ++it) {
            if ((*it) >= start) {
                *it += delta;
            }
        }

        for (int i = start; i <= end; ++i) {
            sortedPreFilterRows.append(i);

            if (!separateLaunchers) {
                if (sortRowInsertQueueStale) {
                    sortRowInsertQueue.clear();
                    sortRowInsertQueueStale = false;
                }

                sortRowInsertQueue.append(sortedPreFilterRows.count() - 1);
            }
        }
    });

    // If we're in manual sort mode, we need to update the sort map on row insertions.
    QObject::connect(concatProxyModel, &QAbstractItemModel::rowsInserted, q, [this](const QModelIndex &parent, int start, int end) {
        Q_UNUSED(parent)
        Q_UNUSED(start)
        Q_UNUSED(end)

        if (sortMode == SortManual) {
            updateManualSortMap();
        }
    });

    // If we're in manual sort mode, we need to update the sort map after row removals.
    QObject::connect(concatProxyModel, &QAbstractItemModel::rowsRemoved, q, [this](const QModelIndex &parent, int first, int last) {
        Q_UNUSED(parent)

        if (sortMode != SortManual) {
            return;
        }

        if (sortRowInsertQueueStale) {
            sortRowInsertQueue.clear();
            sortRowInsertQueueStale = false;
        }

        for (int i = first; i <= last; ++i) {
            sortedPreFilterRows.removeOne(i);
        }

        const int delta = (last - first) + 1;
        for (auto it = sortedPreFilterRows.begin(); it != sortedPreFilterRows.end(); ++it) {
            if ((*it) > last) {
                *it -= delta;
            }
        }
    });

    filterProxyModel = new TaskFilterProxyModel(q);
    filterProxyModel->setSourceModel(concatProxyModel);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::virtualDesktopChanged, q, &TasksModel::virtualDesktopChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::screenGeometryChanged, q, &TasksModel::screenGeometryChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::regionGeometryChanged, q, &TasksModel::regionGeometryChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::activityChanged, q, &TasksModel::activityChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterByVirtualDesktopChanged, q, &TasksModel::filterByVirtualDesktopChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterByCurrentVirtualDesktopChanged, q, &TasksModel::filterByCurrentVirtualDesktopChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterByScreenChanged, q, &TasksModel::filterByScreenChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterByActivityChanged, q, &TasksModel::filterByActivityChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterByRegionChanged, q, &TasksModel::filterByRegionChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterMinimizedChanged, q, &TasksModel::filterMinimizedChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterNotMinimizedChanged, q, &TasksModel::filterNotMinimizedChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterNotMaximizedChanged, q, &TasksModel::filterNotMaximizedChanged);
    QObject::connect(filterProxyModel, &TaskFilterProxyModel::filterHiddenChanged, q, &TasksModel::filterHiddenChanged);

    groupingProxyModel = new TaskGroupingProxyModel(q);
    groupingProxyModel->setSourceModel(filterProxyModel);
    QObject::connect(groupingProxyModel, &TaskGroupingProxyModel::groupModeChanged, q, &TasksModel::groupModeChanged);
    QObject::connect(groupingProxyModel, &TaskGroupingProxyModel::blacklistedAppIdsChanged, q, &TasksModel::groupingAppIdBlacklistChanged);
    QObject::connect(groupingProxyModel, &TaskGroupingProxyModel::blacklistedLauncherUrlsChanged, q, &TasksModel::groupingLauncherUrlBlacklistChanged);

    QObject::connect(groupingProxyModel, &QAbstractItemModel::rowsInserted, q, [this](const QModelIndex &parent, int first, int last) {
        if (parent.isValid()) {
            if (sortMode == SortManual) {
                consolidateManualSortMapForGroup(parent);
            }

            // Existence of a group means everything below this has already been done.
            return;
        }

        bool demandsAttentionUpdateNeeded = false;

        for (int i = first; i <= last; ++i) {
            const QModelIndex &sourceIndex = groupingProxyModel->index(i, 0);
            const QString &appId = sourceIndex.data(AbstractTasksModel::AppId).toString();

            if (sourceIndex.data(AbstractTasksModel::IsDemandingAttention).toBool()) {
                demandsAttentionUpdateNeeded = true;
            }
        }

        // If there were launchers or startup tasks related to the new window,
        // filter them out.
        q->invalidateFilter();

        if (!anyTaskDemandsAttention && demandsAttentionUpdateNeeded) {
            updateAnyTaskDemandsAttention();
        }
    });

    QObject::connect(groupingProxyModel, &QAbstractItemModel::rowsAboutToBeRemoved, q, [this](const QModelIndex &parent, int first, int last) {
        // We can ignore group members.
        if (parent.isValid()) {
            return;
        }

        for (int i = first; i <= last; ++i) {
            const QModelIndex &sourceIndex = groupingProxyModel->index(i, 0);

            // When a window or startup task is removed, we have to trigger a re-filter of
            // our launchers to (possibly) pop them back in.
            // NOTE: An older revision of this code compared the window and startup tasks
            // to the launchers to figure out which launchers should be re-filtered. This
            // was fine until we discovered that certain applications (e.g. Google Chrome)
            // change their window metadata specifically during tear-down, sometimes
            // breaking TaskTools::appsMatch (it's a race) and causing the associated
            // launcher to remain hidden. Therefore we now consider any top-level window or
            // startup task removal a trigger to re-filter all launchers. We don't do this
            // in response to the window metadata changes (even though it would be strictly
            // more correct, as then-ending identity match-up was what caused the launcher
            // to be hidden) because we don't want the launcher and window/startup task to
            // briefly co-exist in the model.
            if (!launcherCheckNeeded && launcherTasksModel
                && (sourceIndex.data(AbstractTasksModel::IsWindow).toBool() || sourceIndex.data(AbstractTasksModel::IsStartup).toBool())) {
                launcherCheckNeeded = true;
            }
        }
    });

    QObject::connect(filterProxyModel, &QAbstractItemModel::rowsRemoved, q, [this](const QModelIndex &parent, int first, int last) {
        Q_UNUSED(parent)
        Q_UNUSED(first)
        Q_UNUSED(last)

        if (launcherCheckNeeded) {
            for (int i = 0; i < filterProxyModel->rowCount(); ++i) {
                const QModelIndex &idx = filterProxyModel->index(i, 0);

                if (idx.data(AbstractTasksModel::IsLauncher).toBool()) {
                    Q_EMIT filterProxyModel->dataChanged(idx, idx);
                }
            }

            launcherCheckNeeded = false;
        }

        // One of the removed tasks might have been demanding attention, but
        // we can't check the state after the window has been closed already,
        // so we always have to do a full update.
        if (anyTaskDemandsAttention) {
            updateAnyTaskDemandsAttention();
        }
    });

    // Update anyTaskDemandsAttention on source data changes.
    QObject::connect(groupingProxyModel,
                     &QAbstractItemModel::dataChanged,
                     q,
                     [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList<int> &roles) {
                         Q_UNUSED(bottomRight)

                         // We can ignore group members.
                         if (topLeft.parent().isValid()) {
                             return;
                         }

                         if (roles.isEmpty() || roles.contains(AbstractTasksModel::IsDemandingAttention)) {
                             updateAnyTaskDemandsAttention();
                         }

                         if (roles.isEmpty() || roles.contains(AbstractTasksModel::AppId)) {
                             for (int i = topLeft.row(); i <= bottomRight.row(); ++i) {
                                 const QModelIndex &sourceIndex = groupingProxyModel->index(i, 0);

                                 // When a window task changes identity to one we have a launcher for, cause
                                 // the launcher to be re-filtered.
                                 if (sourceIndex.data(AbstractTasksModel::IsWindow).toBool()) {
                                     for (int i = 0; i < filterProxyModel->rowCount(); ++i) {
                                         const QModelIndex &filterIndex = filterProxyModel->index(i, 0);

                                         if (!filterIndex.data(AbstractTasksModel::IsLauncher).toBool()) {
                                             continue;
                                         }

                                         if (appsMatch(sourceIndex, filterIndex)) {
                                             Q_EMIT filterProxyModel->dataChanged(filterIndex, filterIndex);
                                         }
                                     }
                                 }
                             }
                         }
                     });

    // Update anyTaskDemandsAttention on source model resets.
    QObject::connect(groupingProxyModel, &QAbstractItemModel::modelReset, q, [this]() {
        updateAnyTaskDemandsAttention();
    });
}

void TasksModel::Private::updateAnyTaskDemandsAttention()
{
    bool taskFound = false;

    for (int i = 0; i < groupingProxyModel->rowCount(); ++i) {
        if (groupingProxyModel->index(i, 0).data(AbstractTasksModel::IsDemandingAttention).toBool()) {
            taskFound = true;
            break;
        }
    }

    if (taskFound != anyTaskDemandsAttention) {
        anyTaskDemandsAttention = taskFound;
        Q_EMIT q->anyTaskDemandsAttentionChanged();
    }
}

void TasksModel::Private::initLauncherTasksModel()
{
    if (launcherTasksModel) {
        return;
    }

    launcherTasksModel = new LauncherTasksModel(q);
    QObject::connect(launcherTasksModel, &LauncherTasksModel::launcherListChanged, q, &TasksModel::launcherListChanged);
    QObject::connect(launcherTasksModel, &LauncherTasksModel::launcherListChanged, q, &TasksModel::updateLauncherCount);

    // TODO: On the assumptions that adding/removing launchers is a rare event and
    // the HasLaunchers data role is rarely used, this refreshes it for all rows in
    // the model. If those assumptions are proven wrong later, this could be
    // optimized to only refresh non-launcher rows matching the inserted or about-
    // to-be-removed launcherTasksModel rows using TaskTools::appsMatch().
    QObject::connect(launcherTasksModel, &LauncherTasksModel::launcherListChanged, q, [this]() {
        Q_EMIT q->dataChanged(q->index(0, 0), q->index(q->rowCount() - 1, 0), QList<int>{AbstractTasksModel::HasLauncher});
    });

    // data() implements AbstractTasksModel::HasLauncher by checking with
    // TaskTools::appsMatch, which evaluates ::AppId and ::LauncherUrlWithoutIcon.
    QObject::connect(q, &QAbstractItemModel::dataChanged, q, [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList<int> &roles) {
        if (roles.contains(AbstractTasksModel::AppId) || roles.contains(AbstractTasksModel::LauncherUrlWithoutIcon)) {
            for (int i = topLeft.row(); i <= bottomRight.row(); ++i) {
                const QModelIndex &index = q->index(i, 0);

                if (!index.data(AbstractTasksModel::IsLauncher).toBool()) {
                    Q_EMIT q->dataChanged(index, index, QList<int>{AbstractTasksModel::HasLauncher});
                }
            }
        }
    });

    concatProxyModel->addSourceModel(launcherTasksModel);
}

void TasksModel::Private::updateManualSortMap()
{
    // Empty map; full sort.
    if (sortedPreFilterRows.isEmpty()) {
        sortedPreFilterRows.reserve(concatProxyModel->rowCount());

        for (int i = 0; i < concatProxyModel->rowCount(); ++i) {
            sortedPreFilterRows.append(i);
        }

        // Full sort.
        TasksModelLessThan lt(concatProxyModel, q, false);
        std::ranges::stable_sort(sortedPreFilterRows, lt);

        // Consolidate sort map entries for groups.
        if (q->groupMode() != GroupDisabled) {
            for (int i = 0; i < groupingProxyModel->rowCount(); ++i) {
                const QModelIndex &groupingIndex = groupingProxyModel->index(i, 0);

                if (groupingIndex.data(AbstractTasksModel::IsGroupParent).toBool()) {
                    consolidateManualSortMapForGroup(groupingIndex);
                }
            }
        }

        return;
    }

    // Existing map; check whether launchers need sorting by launcher list position.
    if (separateLaunchers) {
        // Sort only launchers.
        TasksModelLessThan lt(concatProxyModel, q, true);
        std::ranges::stable_sort(sortedPreFilterRows, lt);
        // Otherwise process any entries in the insert queue and move them intelligently
        // in the sort map.
    } else {
        QMutableListIterator<int> i(sortRowInsertQueue);

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

            const int row = i.value();
            const QModelIndex &idx = concatProxyModel->index(sortedPreFilterRows.at(row), 0);

            // If a window task is currently hidden, we may want to keep it in the queue
            // to sort it in later once it gets revealed.
            // This is important in concert with taskmanagerrulesrc's SkipTaskbar key, which
            // is used to hide window tasks which update from bogus to useful window metadata
            // early in startup. Once the task no longer uses bogus metadata listed in the
            // config key, its SkipTaskbar role changes to false, and then is it possible to
            // sort the task adjacent to its launcher in the code below.
            if (idx.data(AbstractTasksModel::IsWindow).toBool() && idx.data(AbstractTasksModel::SkipTaskbar).toBool()) {
                // Since we're going to keep a row in the queue for now, make sure to
                // mark the queue as stale so it's cleared on appends or row removals
                // when they follow this sorting attempt. This frees us from having to
                // update the indices in the queue to keep them valid.
                // This means windowing system changes such as the opening or closing
                // of a window task which happen during the time period that a window
                // task has known bogus metadata, can upset what we're trying to
                // achieve with this exception. However, due to the briefness of the
                // time period and usage patterns, this is improbable, making this
                // likely good enough. If it turns out not to be, this decision may be
                // revisited later.
                sortRowInsertQueueStale = true;

                break;
            } else {
                i.remove();
            }

            bool moved = false;

            // Try to move the task up to its right-most app sibling, unless this
            // is us sorting in a launcher list for the first time.
            if (launchersEverSet && !idx.data(AbstractTasksModel::IsLauncher).toBool()) {
                for (int j = (row - 1); j >= 0; --j) {
                    const QModelIndex &concatProxyIndex = concatProxyModel->index(sortedPreFilterRows.at(j), 0);

                    // Once we got a match, check if the filter model accepts the potential
                    // sibling. We don't want to sort new tasks in next to tasks it will
                    // filter out once it sees it anyway.
                    if (appsMatch(concatProxyIndex, idx) && filterProxyModel->acceptsRow(concatProxyIndex.row())) {
                        sortedPreFilterRows.move(row, j + 1);
                        moved = true;

                        break;
                    }
                }
            }

            int insertPos = 0;

            // If unsuccessful or skipped, and the new task is a launcher, put after
            // the rightmost launcher or launcher-backed task in the map, or failing
            // that at the start of the map.
            if (!moved && idx.data(AbstractTasksModel::IsLauncher).toBool()) {
                for (int j = 0; j < row; ++j) {
                    const QModelIndex &concatProxyIndex = concatProxyModel->index(sortedPreFilterRows.at(j), 0);

                    if (concatProxyIndex.data(AbstractTasksModel::IsLauncher).toBool()
                        || launcherTasksModel->launcherPosition(concatProxyIndex.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl()) != -1) {
                        insertPos = j + 1;
                    } else {
                        break;
                    }
                }

                sortedPreFilterRows.move(row, insertPos);
                moved = true;
            }

            // If we sorted in a launcher and it's the first time we're sorting in a
            // launcher list, move existing windows to the launcher position now.
            if (moved && !launchersEverSet) {
                for (int j = (sortedPreFilterRows.count() - 1); j >= 0; --j) {
                    const QModelIndex &concatProxyIndex = concatProxyModel->index(sortedPreFilterRows.at(j), 0);

                    if (!concatProxyIndex.data(AbstractTasksModel::IsLauncher).toBool()
                        && idx.data(AbstractTasksModel::LauncherUrlWithoutIcon) == concatProxyIndex.data(AbstractTasksModel::LauncherUrlWithoutIcon)) {
                        sortedPreFilterRows.move(j, insertPos);

                        if (insertPos > j) {
                            --insertPos;
                        }
                    }
                }
            }
        }
    }
}

void TasksModel::Private::consolidateManualSortMapForGroup(const QModelIndex &groupingProxyIndex)
{
    // Consolidates sort map entries for a group's items to be contiguous
    // after the group's first item and the same order as in groupingProxyModel.

    const int childCount = groupingProxyModel->rowCount(groupingProxyIndex);

    if (!childCount) {
        return;
    }

    const QModelIndex &leader = groupingProxyModel->index(0, 0, groupingProxyIndex);
    const QModelIndex &preFilterLeader = filterProxyModel->mapToSource(groupingProxyModel->mapToSource(leader));

    // We're moving the trailing children to the sort map position of
    // the first child, so we're skipping the first child.
    for (int i = 1; i < childCount; ++i) {
        const QModelIndex &child = groupingProxyModel->index(i, 0, groupingProxyIndex);
        const QModelIndex &preFilterChild = filterProxyModel->mapToSource(groupingProxyModel->mapToSource(child));
        const int leaderPos = sortedPreFilterRows.indexOf(preFilterLeader.row());
        const int childPos = sortedPreFilterRows.indexOf(preFilterChild.row());
        const int insertPos = (leaderPos + i) + ((leaderPos + i) > childPos ? -1 : 0);
        sortedPreFilterRows.move(childPos, insertPos);
    }
}

void TasksModel::Private::updateGroupInline()
{
    if (usedByQml && !componentComplete) {
        return;
    }

    bool hadSourceModel = (q->sourceModel() != nullptr);

    if (q->groupMode() != GroupDisabled && groupInline) {
        if (flattenGroupsProxyModel) {
            return;
        }

        // Exempting tasks which demand attention from grouping is not
        // necessary when all group children are shown inline anyway
        // and would interfere with our sort-tasks-together goals.
        groupingProxyModel->setGroupDemandingAttention(true);

        // Likewise, ignore the window tasks threshold when making
        // grouping decisions.
        groupingProxyModel->setWindowTasksThreshold(-1);

        flattenGroupsProxyModel = new FlattenTaskGroupsProxyModel(q);
        flattenGroupsProxyModel->setSourceModel(groupingProxyModel);

        abstractTasksSourceModel = flattenGroupsProxyModel;
        q->setSourceModel(flattenGroupsProxyModel);

        if (sortMode == SortManual) {
            forceResort();
        }
    } else {
        if (hadSourceModel && !flattenGroupsProxyModel) {
            return;
        }

        groupingProxyModel->setGroupDemandingAttention(false);
        groupingProxyModel->setWindowTasksThreshold(groupingWindowTasksThreshold);

        abstractTasksSourceModel = groupingProxyModel;
        q->setSourceModel(groupingProxyModel);

        delete std::exchange(flattenGroupsProxyModel, nullptr);

        if (hadSourceModel && sortMode == SortManual) {
            forceResort();
        }
    }

    // Minor optimization: We only make these connections after we populate for
    // the first time to avoid some churn.
    if (!hadSourceModel) {
        QObject::connect(q, &QAbstractItemModel::rowsInserted, q, &TasksModel::updateLauncherCount, Qt::UniqueConnection);
        QObject::connect(q, &QAbstractItemModel::rowsRemoved, q, &TasksModel::updateLauncherCount, Qt::UniqueConnection);
        QObject::connect(q, &QAbstractItemModel::modelReset, q, &TasksModel::updateLauncherCount, Qt::UniqueConnection);

        QObject::connect(q, &QAbstractItemModel::rowsInserted, q, &TasksModel::countChanged, Qt::UniqueConnection);
        QObject::connect(q, &QAbstractItemModel::rowsRemoved, q, &TasksModel::countChanged, Qt::UniqueConnection);
        QObject::connect(q, &QAbstractItemModel::modelReset, q, &TasksModel::countChanged, Qt::UniqueConnection);
    }
}

QModelIndex TasksModel::Private::preFilterIndex(const QModelIndex &sourceIndex) const
{
    // Only in inline grouping mode, we have an additional proxy layer.
    if (flattenGroupsProxyModel) {
        return filterProxyModel->mapToSource(groupingProxyModel->mapToSource(flattenGroupsProxyModel->mapToSource(sourceIndex)));
    } else {
        return filterProxyModel->mapToSource(groupingProxyModel->mapToSource(sourceIndex));
    }
}

void TasksModel::Private::updateActivityTaskCounts()
{
    // Collects the number of window tasks on each activity.

    activityTaskCounts.clear();

    if (!windowTasksModel || !activityInfo) {
        return;
    }

    for (const auto activities = activityInfo->runningActivities(); const QString &activity : activities) {
        activityTaskCounts.insert(activity, 0);
    }

    for (int i = 0; i < windowTasksModel->rowCount(); ++i) {
        const QModelIndex &windowIndex = windowTasksModel->index(i, 0);
        const QStringList &activities = windowIndex.data(AbstractTasksModel::Activities).toStringList();

        if (activities.isEmpty()) {
            QMutableHashIterator<QString, int> it(activityTaskCounts);

            while (it.hasNext()) {
                it.next();
                it.setValue(it.value() + 1);
            }
        } else {
            for (const QString &activity : activities) {
                ++activityTaskCounts[activity];
            }
        }
    }
}

void TasksModel::Private::forceResort()
{
    // HACK: This causes QSortFilterProxyModel to run all rows through
    // our lessThan() implementation again.
    q->setDynamicSortFilter(false);
    q->setDynamicSortFilter(true);
}

std::optional<bool> TasksModel::Private::lessThanByVirtualDesktop(const QModelIndex &left, const QModelIndex &right) const
{
    const bool leftAll = left.data(AbstractTasksModel::IsOnAllVirtualDesktops).toBool();
    const bool rightAll = right.data(AbstractTasksModel::IsOnAllVirtualDesktops).toBool();

    if (leftAll && !rightAll) {
        return true;
    }

    if (!leftAll && rightAll) {
        return false;
    }

    if (!leftAll && !rightAll) {
        const auto getDesktop = [this](const QModelIndex &model) {
            const QVariantList modelDesktops = model.data(AbstractTasksModel::VirtualDesktops).toList();
            QVariant modelDesktop;
            int modelDesktopPos = virtualDesktopInfo->numberOfDesktops();
            for (const QVariant &desktop : modelDesktops) {
                const int desktopPos = virtualDesktopInfo->position(desktop);

                if (desktopPos <= modelDesktopPos) {
                    modelDesktop = desktop;
                    modelDesktopPos = desktopPos;
                }
            }
            return modelDesktop;
        };

        const QVariant leftDesktop = getDesktop(left);
        const QVariant rightDesktop = getDesktop(right);

        if (!leftDesktop.isNull() && !rightDesktop.isNull() && (leftDesktop != rightDesktop)) {
            return (virtualDesktopInfo->position(leftDesktop) < virtualDesktopInfo->position(rightDesktop));
        } else if (!leftDesktop.isNull() && rightDesktop.isNull()) {
            return false;
        } else if (leftDesktop.isNull() && !rightDesktop.isNull()) {
            return true;
        }
    }

    // We couldn't determine the order (e.g., both on all desktops or the same desktop)
    return std::nullopt;
}

bool TasksModel::Private::lessThan(const QModelIndex &left, const QModelIndex &right, bool sortOnlyLaunchers) const
{
    // Launcher tasks go first.
    // When launchInPlace is enabled, startup and window tasks are sorted
    // as the launchers they replace (see also move()).

    if (separateLaunchers) {
        const bool leftIsLauncher = left.data(AbstractTasksModel::IsLauncher).toBool();
        const bool rightIsLauncher = right.data(AbstractTasksModel::IsLauncher).toBool();

        if (leftIsLauncher && rightIsLauncher) {
            return (left.row() < right.row());
        }

        const int leftPos = q->launcherPosition(left.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl());
        const int rightPos = q->launcherPosition(right.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl());

        if (leftIsLauncher && !rightIsLauncher) {
            if (launchInPlace) {
                if (rightPos != -1) {
                    return (leftPos < rightPos);
                }
            }

            return true;
        }

        if (!leftIsLauncher && rightIsLauncher) {
            if (launchInPlace) {
                if (leftPos != -1) {
                    return (leftPos < rightPos);
                }
            }

            return false;
        }

        // neither is a launcher
        if (launchInPlace) {
            if (leftPos != -1 && rightPos != -1) {
                return (leftPos < rightPos);
            } else if (leftPos != -1 && rightPos == -1) {
                return true;
            } else if (leftPos == -1 && rightPos != -1) {
                return false;
            }
        }
    }

    // If told to stop after launchers we fall through to the existing map if it exists.
    if (sortOnlyLaunchers && !sortedPreFilterRows.isEmpty()) {
        return (sortedPreFilterRows.indexOf(left.row()) < sortedPreFilterRows.indexOf(right.row()));
    }

    // Sort other cases by sort mode.
    switch (sortMode) {
    case SortDisabled: {
        return (left.row() < right.row());
    }

    case SortWindowPositionHorizontal: {
        const bool leftIsStartup = left.data(AbstractTasksModel::IsStartup).toBool();
        const bool rightIsStartup = right.data(AbstractTasksModel::IsStartup).toBool();

        if (leftIsStartup && rightIsStartup) {
            return (left.row() < right.row());
        } else if (leftIsStartup && !rightIsStartup) {
            return false;
        } else if (!leftIsStartup && rightIsStartup) {
            return true;
        }

        if (auto result = lessThanByVirtualDesktop(left, right)) {
            return *result;
        }

        const auto leftGeom = left.data(AbstractTasksModel::Geometry).value<QRect>();
        const auto rightGeom = right.data(AbstractTasksModel::Geometry).value<QRect>();

        if (leftGeom.x() != rightGeom.x()) {
            if (QGuiApplication::isRightToLeft()) {
                return leftGeom.right() > rightGeom.right();
            } else {
                return leftGeom.x() < rightGeom.x();
            }
        }
        if (leftGeom.y() != rightGeom.y()) {
            return leftGeom.y() < rightGeom.y();
        }

        Q_FALLTHROUGH();
    }

    case SortLastActivated: {
        const auto getSortDateTime = [](const QModelIndex &model) {
            // Check if the task is in a group
            const QModelIndex topMost = model.parent().isValid() ? model.parent() : model;
            QDateTime sortDateTime = topMost.data(AbstractTasksModel::LastActivated).toDateTime();
            if (!sortDateTime.isValid()) {
                sortDateTime = model.data(Qt::DisplayRole).toDateTime();
            }
            return sortDateTime;
        };

        const QDateTime leftSortDateTime = getSortDateTime(left);
        const QDateTime rightSortDateTime = getSortDateTime(right);

        if (leftSortDateTime != rightSortDateTime) {
            // Move latest to leftmost
            return leftSortDateTime > rightSortDateTime;
        }

        Q_FALLTHROUGH();
    }
    // fall through
    case SortVirtualDesktop: {

        if (auto result = lessThanByVirtualDesktop(left, right)) {
            return *result;
        }

        Q_FALLTHROUGH();
    }
    // fall through
    case SortActivity: {
        // updateActivityTaskCounts() counts the number of window tasks on each
        // activity. This will sort tasks by comparing a cumulative score made
        // up of the task counts for each activity a task is assigned to, and
        // otherwise fall through to alphabetical sorting.
        const auto getScore = [this](const QModelIndex &model) {
            const QStringList activities = model.data(AbstractTasksModel::Activities).toStringList();
            const int score = std::accumulate(activities.cbegin(), activities.cend(), -1, [this](int a, const QString &activity) {
                return a + activityTaskCounts[activity];
            });
            return score;
        };

        int leftScore = getScore(left);
        int rightScore = getScore(right);

        if (leftScore == -1 || rightScore == -1) {
            const int sumScore = std::accumulate(activityTaskCounts.constBegin(), activityTaskCounts.constEnd(), 0);

            if (leftScore == -1) {
                leftScore = sumScore;
            }

            if (rightScore == -1) {
                rightScore = sumScore;
            }
        }

        if (leftScore != rightScore) {
            return (leftScore > rightScore);
        }
    }
    // fall through
    case SortAlpha:
    // fall through
    default: {
        // The overall goal of alphabetic sorting is to sort tasks belonging to the
        // same app together, while sorting the resulting sets alphabetically among
        // themselves by the app name. The following code tries to achieve this by
        // going for AppName first, and falling back to DisplayRole - which for
        // window-type tasks generally contains the window title - if AppName is
        // not available. When comparing tasks with identical resulting sort strings,
        // we sort them by the source model order (i.e. insertion/creation). Older
        // versions of this code compared tasks by a concatenation of AppName and
        // DisplayRole at all times, but always sorting by the window title does more
        // than our goal description - and can cause tasks within an app's set to move
        // around when window titles change, which is a nuisance for users (especially
        // in case of tabbed apps that have the window title reflect the active tab,
        // e.g. web browsers). To recap, the common case is "sort by AppName, then
        // insertion order", only swapping out AppName for DisplayRole (i.e. window
        // title) when necessary.

        QString leftSortString = left.data(AbstractTasksModel::AppName).toString();

        if (leftSortString.isEmpty()) {
            leftSortString = left.data(Qt::DisplayRole).toString();
        }

        QString rightSortString = right.data(AbstractTasksModel::AppName).toString();

        if (rightSortString.isEmpty()) {
            rightSortString = right.data(Qt::DisplayRole).toString();
        }

        const int sortResult = leftSortString.localeAwareCompare(rightSortString);

        // If the string are identical fall back to source model (creation/append) order.
        if (sortResult == 0) {
            return (left.row() < right.row());
        }

        return (sortResult < 0);
    }
    }
}

TasksModel::TasksModel(QObject *parent)
    : QSortFilterProxyModel(parent)
    , d(new Private(this))
{
    d->initModels();

    // Start sorting.
    sort(0);

    connect(this, &TasksModel::sourceModelChanged, this, &TasksModel::countChanged);

    // Private::updateGroupInline() sets our source model, populating the model. We
    // delay running this until the QML runtime had a chance to call our implementation
    // of QQmlParserStatus::classBegin(), setting Private::usedByQml to true. If used
    // by QML, Private::updateGroupInline() will abort if the component is not yet
    // complete, instead getting called through QQmlParserStatus::componentComplete()
    // only after all properties have been set. This avoids delegate churn in Qt Quick
    // views using the model. If not used by QML, Private::updateGroupInline() will run
    // directly.
    QTimer::singleShot(0, this, [this]() {
        d->updateGroupInline();
    });
}

TasksModel::~TasksModel() = default;

QHash<int, QByteArray> TasksModel::roleNames() const
{
    if (d->windowTasksModel) {
        return d->windowTasksModel->roleNames();
    }

    return {};
}

int TasksModel::rowCount(const QModelIndex &parent) const
{
    return QSortFilterProxyModel::rowCount(parent);
}

QVariant TasksModel::data(const QModelIndex &proxyIndex, int role) const
{
    if (role == AbstractTasksModel::HasLauncher && proxyIndex.isValid() && proxyIndex.row() < rowCount()) {
        if (proxyIndex.data(AbstractTasksModel::IsLauncher).toBool()) {
            return true;
        } else {
            if (!d->launcherTasksModel) {
                return false;
            }
            for (int i = 0; i < d->launcherTasksModel->rowCount(); ++i) {
                const QModelIndex &launcherIndex = d->launcherTasksModel->index(i, 0);

                if (appsMatch(proxyIndex, launcherIndex)) {
                    return true;
                }
            }

            return false;
        }
    } else if (rowCount(proxyIndex) && role == AbstractTasksModel::WinIdList) {
        QVariantList winIds;

        for (int i = 0; i < rowCount(proxyIndex); ++i) {
            winIds.append(index(i, 0, proxyIndex).data(AbstractTasksModel::WinIdList).toList());
        }

        return winIds;
    }

    return QSortFilterProxyModel::data(proxyIndex, role);
}

void TasksModel::updateLauncherCount()
{
    if (!d->launcherTasksModel) {
        return;
    }

    int count = 0;

    for (int i = 0; i < rowCount(); ++i) {
        if (index(i, 0).data(AbstractTasksModel::IsLauncher).toBool()) {
            ++count;
        }
    }

    if (d->launcherCount != count) {
        d->launcherCount = count;
        Q_EMIT launcherCountChanged();
    }
}

int TasksModel::launcherCount() const
{
    return d->launcherCount;
}

bool TasksModel::anyTaskDemandsAttention() const
{
    return d->anyTaskDemandsAttention;
}

QVariant TasksModel::virtualDesktop() const
{
    return d->filterProxyModel->virtualDesktop();
}

void TasksModel::setVirtualDesktop(const QVariant &desktop)
{
    d->filterProxyModel->setVirtualDesktop(desktop);
}

QRect TasksModel::screenGeometry() const
{
    return d->filterProxyModel->screenGeometry();
}

void TasksModel::setScreenGeometry(const QRect &geometry)
{
    d->filterProxyModel->setScreenGeometry(geometry);
}

QRect TasksModel::regionGeometry() const
{
    return d->filterProxyModel->regionGeometry();
}

void TasksModel::setRegionGeometry(const QRect &geometry)
{
    d->filterProxyModel->setRegionGeometry(geometry);
}

QString TasksModel::activity() const
{
    return d->filterProxyModel->activity();
}

void TasksModel::setActivity(const QString &activity)
{
    d->filterProxyModel->setActivity(activity);
}

bool TasksModel::filterByVirtualDesktop() const
{
    return d->filterProxyModel->filterByVirtualDesktop();
}

void TasksModel::setFilterByVirtualDesktop(bool filter)
{
    d->filterProxyModel->setFilterByVirtualDesktop(filter);
}

bool TasksModel::filterByCurrentVirtualDesktop() const
{
    return d->filterProxyModel->filterByCurrentVirtualDesktop();
}

void TasksModel::setFilterByCurrentVirtualDesktop(bool filter)
{
    d->filterProxyModel->setFilterByCurrentVirtualDesktop(filter);
}

bool TasksModel::filterByScreen() const
{
    return d->filterProxyModel->filterByScreen();
}

void TasksModel::setFilterByScreen(bool filter)
{
    d->filterProxyModel->setFilterByScreen(filter);
}

bool TasksModel::filterByActivity() const
{
    return d->filterProxyModel->filterByActivity();
}

void TasksModel::setFilterByActivity(bool filter)
{
    d->filterProxyModel->setFilterByActivity(filter);
}

RegionFilterMode::Mode TasksModel::filterByRegion() const
{
    return d->filterProxyModel->filterByRegion();
}

void TasksModel::setFilterByRegion(RegionFilterMode::Mode mode)
{
    d->filterProxyModel->setFilterByRegion(mode);
}

bool TasksModel::filterMinimized() const
{
    return d->filterProxyModel->filterMinimized();
}

void TasksModel::setFilterMinimized(bool filter)
{
    d->filterProxyModel->setFilterMinimized(filter);
}

bool TasksModel::filterNotMinimized() const
{
    return d->filterProxyModel->filterNotMinimized();
}

void TasksModel::setFilterNotMinimized(bool filter)
{
    d->filterProxyModel->setFilterNotMinimized(filter);
}

bool TasksModel::filterNotMaximized() const
{
    return d->filterProxyModel->filterNotMaximized();
}

void TasksModel::setFilterNotMaximized(bool filter)
{
    d->filterProxyModel->setFilterNotMaximized(filter);
}

bool TasksModel::filterHidden() const
{
    return d->filterProxyModel->filterHidden();
}

void TasksModel::setFilterHidden(bool filter)
{
    d->filterProxyModel->setFilterHidden(filter);
}

TasksModel::SortMode TasksModel::sortMode() const
{
    return d->sortMode;
}

void TasksModel::setSortMode(SortMode mode)
{
    if (d->sortMode != mode) {
        if (mode == SortManual) {
            d->updateManualSortMap();
        } else if (d->sortMode == SortManual) {
            d->sortedPreFilterRows.clear();
        }

        if (mode == SortVirtualDesktop || mode == SortWindowPositionHorizontal) {
            d->virtualDesktopInfo = virtualDesktopInfo();
            setSortRole(AbstractTasksModel::VirtualDesktops);
        } else if (d->sortMode == SortVirtualDesktop || d->sortMode == SortWindowPositionHorizontal) {
            d->virtualDesktopInfo = nullptr;
            setSortRole(Qt::DisplayRole);
        }

        if (mode == SortActivity) {
            d->activityInfo = activityInfo();

            d->updateActivityTaskCounts();
            setSortRole(AbstractTasksModel::Activities);
        } else if (d->sortMode == SortActivity) {
            d->activityInfo = nullptr;

            d->activityTaskCounts.clear();
            setSortRole(Qt::DisplayRole);
        }

        if (mode == SortLastActivated) {
            setSortRole(AbstractTasksModel::LastActivated);
        }

        d->sortMode = mode;

        d->forceResort();

        Q_EMIT sortModeChanged();
    }
}

bool TasksModel::separateLaunchers() const
{
    return d->separateLaunchers;
}

void TasksModel::setSeparateLaunchers(bool separate)
{
    if (d->separateLaunchers != separate) {
        d->separateLaunchers = separate;

        d->updateManualSortMap();
        d->forceResort();

        Q_EMIT separateLaunchersChanged();
    }
}

bool TasksModel::launchInPlace() const
{
    return d->launchInPlace;
}

void TasksModel::setLaunchInPlace(bool launchInPlace)
{
    if (d->launchInPlace != launchInPlace) {
        d->launchInPlace = launchInPlace;

        d->forceResort();

        Q_EMIT launchInPlaceChanged();
    }
}

TasksModel::GroupMode TasksModel::groupMode() const
{
    if (!d->groupingProxyModel) {
        return GroupDisabled;
    }

    return d->groupingProxyModel->groupMode();
}

bool TasksModel::hideActivatedLaunchers() const
{
    return d->hideActivatedLaunchers;
}

void TasksModel::setHideActivatedLaunchers(bool hideActivatedLaunchers)
{
    if (d->hideActivatedLaunchers != hideActivatedLaunchers) {
        d->hideActivatedLaunchers = hideActivatedLaunchers;

        d->updateManualSortMap();
        invalidateFilter();
        d->forceResort();

        Q_EMIT hideActivatedLaunchersChanged();
    }
}

void TasksModel::setGroupMode(GroupMode mode)
{
    if (d->groupingProxyModel) {
        if (mode == GroupDisabled && d->flattenGroupsProxyModel) {
            d->flattenGroupsProxyModel->setSourceModel(nullptr);
        }

        d->groupingProxyModel->setGroupMode(mode);
        d->updateGroupInline();
    }
}

bool TasksModel::groupInline() const
{
    return d->groupInline;
}

void TasksModel::setGroupInline(bool groupInline)
{
    if (d->groupInline != groupInline) {
        d->groupInline = groupInline;

        d->updateGroupInline();

        Q_EMIT groupInlineChanged();
    }
}

int TasksModel::groupingWindowTasksThreshold() const
{
    return d->groupingWindowTasksThreshold;
}

void TasksModel::setGroupingWindowTasksThreshold(int threshold)
{
    if (d->groupingWindowTasksThreshold != threshold) {
        d->groupingWindowTasksThreshold = threshold;

        if (!d->groupInline && d->groupingProxyModel) {
            d->groupingProxyModel->setWindowTasksThreshold(threshold);
        }

        Q_EMIT groupingWindowTasksThresholdChanged();
    }
}

QStringList TasksModel::groupingAppIdBlacklist() const
{
    if (!d->groupingProxyModel) {
        return {};
    }

    return d->groupingProxyModel->blacklistedAppIds();
}

void TasksModel::setGroupingAppIdBlacklist(const QStringList &list)
{
    if (d->groupingProxyModel) {
        d->groupingProxyModel->setBlacklistedAppIds(list);
    }
}

QStringList TasksModel::groupingLauncherUrlBlacklist() const
{
    if (!d->groupingProxyModel) {
        return {};
    }

    return d->groupingProxyModel->blacklistedLauncherUrls();
}

void TasksModel::setGroupingLauncherUrlBlacklist(const QStringList &list)
{
    if (d->groupingProxyModel) {
        d->groupingProxyModel->setBlacklistedLauncherUrls(list);
    }
}

bool TasksModel::taskReorderingEnabled() const
{
    return dynamicSortFilter();
}

void TasksModel::setTaskReorderingEnabled(bool enabled)
{
    enabled ? setDynamicSortFilter(true) : setDynamicSortFilter(false);

    Q_EMIT taskReorderingEnabledChanged();
}

QStringList TasksModel::launcherList() const
{
    if (d->launcherTasksModel) {
        return d->launcherTasksModel->launcherList();
    }

    return {};
}

void TasksModel::setLauncherList(const QStringList &launchers)
{
    d->initLauncherTasksModel();
    d->launcherTasksModel->setLauncherList(launchers);
    d->launchersEverSet = true;
}

bool TasksModel::requestAddLauncher(const QUrl &url)
{
    d->initLauncherTasksModel();

    bool added = d->launcherTasksModel->requestAddLauncher(url);

    // If using manual and launch-in-place sorting with separate launchers,
    // we need to trigger a sort map update to move any window tasks to
    // their launcher position now.
    if (added && d->sortMode == SortManual && (d->launchInPlace || !d->separateLaunchers)) {
        d->updateManualSortMap();
        d->forceResort();
    }

    return added;
}

bool TasksModel::requestRemoveLauncher(const QUrl &url)
{
    if (d->launcherTasksModel) {
        bool removed = d->launcherTasksModel->requestRemoveLauncher(url);

        // If using manual and launch-in-place sorting with separate launchers,
        // we need to trigger a sort map update to move any window tasks no
        // longer backed by a launcher out of the launcher area.
        if (removed && d->sortMode == SortManual && (d->launchInPlace || !d->separateLaunchers)) {
            d->updateManualSortMap();
            d->forceResort();
        }

        return removed;
    }

    return false;
}

bool TasksModel::requestAddLauncherToActivity(const QUrl &url, const QString &activity)
{
    d->initLauncherTasksModel();

    bool added = d->launcherTasksModel->requestAddLauncherToActivity(url, activity);

    // If using manual and launch-in-place sorting with separate launchers,
    // we need to trigger a sort map update to move any window tasks to
    // their launcher position now.
    if (added && d->sortMode == SortManual && (d->launchInPlace || !d->separateLaunchers)) {
        d->updateManualSortMap();
        d->forceResort();
    }

    return added;
}

bool TasksModel::requestRemoveLauncherFromActivity(const QUrl &url, const QString &activity)
{
    if (d->launcherTasksModel) {
        bool removed = d->launcherTasksModel->requestRemoveLauncherFromActivity(url, activity);

        // If using manual and launch-in-place sorting with separate launchers,
        // we need to trigger a sort map update to move any window tasks no
        // longer backed by a launcher out of the launcher area.
        if (removed && d->sortMode == SortManual && (d->launchInPlace || !d->separateLaunchers)) {
            d->updateManualSortMap();
            d->forceResort();
        }

        return removed;
    }

    return false;
}

QStringList TasksModel::launcherActivities(const QUrl &url)
{
    if (d->launcherTasksModel) {
        return d->launcherTasksModel->launcherActivities(url);
    }

    return {};
}

int TasksModel::launcherPosition(const QUrl &url) const
{
    if (d->launcherTasksModel) {
        return d->launcherTasksModel->launcherPosition(url);
    }

    return -1;
}

void TasksModel::requestActivate(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestActivate(mapToSource(index));
    }
}

void TasksModel::requestNewInstance(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestNewInstance(mapToSource(index));
    }
}

void TasksModel::requestOpenUrls(const QModelIndex &index, const QList<QUrl> &urls)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestOpenUrls(mapToSource(index), urls);
    }
}

void TasksModel::requestClose(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestClose(mapToSource(index));
    }
}

void TasksModel::requestMove(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestMove(mapToSource(index));
    }
}

void TasksModel::requestResize(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestResize(mapToSource(index));
    }
}

void TasksModel::requestToggleMinimized(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleMinimized(mapToSource(index));
    }
}

void TasksModel::requestToggleMaximized(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleMaximized(mapToSource(index));
    }
}

void TasksModel::requestToggleKeepAbove(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleKeepAbove(mapToSource(index));
    }
}

void TasksModel::requestToggleKeepBelow(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleKeepBelow(mapToSource(index));
    }
}

void TasksModel::requestToggleFullScreen(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleFullScreen(mapToSource(index));
    }
}

void TasksModel::requestToggleShaded(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleShaded(mapToSource(index));
    }
}

void TasksModel::requestToggleNoBorder(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleNoBorder(mapToSource(index));
    }
}

void TasksModel::requestToggleExcludeFromCapture(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestToggleExcludeFromCapture(mapToSource(index));
    }
}

void TasksModel::requestVirtualDesktops(const QModelIndex &index, const QVariantList &desktops)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestVirtualDesktops(mapToSource(index), desktops);
    }
}

void TasksModel::requestNewVirtualDesktop(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        d->abstractTasksSourceModel->requestNewVirtualDesktop(mapToSource(index));
    }
}

void TasksModel::requestActivities(const QModelIndex &index, const QStringList &activities)
{
    if (index.isValid() && index.model() == this) {
        d->groupingProxyModel->requestActivities(mapToSource(index), activities);
    }
}

void TasksModel::requestPublishDelegateGeometry(const QModelIndex &index, const QRect &geometry, QObject *delegate)
{
    if (!index.isValid() || index.model() != this || !index.data(AbstractTasksModel::IsWindow).toBool()) {
        return;
    }
    d->abstractTasksSourceModel->requestPublishDelegateGeometry(mapToSource(index), geometry, delegate);
}

void TasksModel::requestToggleGrouping(const QModelIndex &index)
{
    if (index.isValid() && index.model() == this) {
        const QModelIndex &target = (d->flattenGroupsProxyModel ? d->flattenGroupsProxyModel->mapToSource(mapToSource(index)) : mapToSource(index));
        d->groupingProxyModel->requestToggleGrouping(target);
    }
}

bool TasksModel::move(int row, int newPos, const QModelIndex &parent)
{
    /*
     * NOTE After doing any modification in TasksModel::move, make sure fixes listed below are not regressed.
     * - https://bugs.kde.org/444816
     * - https://bugs.kde.org/448912
     * - https://invent.kde.org/plasma/plasma-workspace/-/commit/ea51795e8c571513e1ff583350ab8649bc857fc2
     */

    if (d->sortMode != SortManual || row == newPos || newPos < 0 || newPos >= rowCount(parent)) {
        return false;
    }

    const QModelIndex &idx = index(row, 0, parent);
    bool isLauncherMove = false;

    // Figure out if we're moving a launcher so we can run barrier checks.
    if (idx.isValid()) {
        if (idx.data(AbstractTasksModel::IsLauncher).toBool()) {
            isLauncherMove = true;
            // When using launch-in-place sorting, launcher-backed window tasks act as launchers.
        } else if ((d->launchInPlace || !d->separateLaunchers) && idx.data(AbstractTasksModel::IsWindow).toBool()) {
            const QUrl &launcherUrl = idx.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
            const int launcherPos = launcherPosition(launcherUrl);

            if (launcherPos != -1) {
                isLauncherMove = true;
            }
        }
    } else {
        return false;
    }

    if (d->separateLaunchers && !parent.isValid() /* Exclude tasks in a group */) {
        int firstTask = 0;
        if (d->launcherTasksModel) {
            if (d->launchInPlace) {
                firstTask = d->launcherTasksModel->rowCountForActivity(activity());
            } else {
                firstTask = launcherCount();
            }
        }

        // Don't allow launchers to be moved past the last launcher.
        if (isLauncherMove && newPos >= firstTask) {
            return false;
        }

        // Don't allow tasks to be moved into the launchers.
        if (!isLauncherMove && newPos < firstTask) {
            return false;
        }
    }

    // Treat flattened-out groups as single items.
    if (d->flattenGroupsProxyModel) {
        QModelIndex groupingRowIndex = d->flattenGroupsProxyModel->mapToSource(mapToSource(index(row, 0)));
        const QModelIndex &groupingRowIndexParent = groupingRowIndex.parent();
        QModelIndex groupingNewPosIndex = d->flattenGroupsProxyModel->mapToSource(mapToSource(index(newPos, 0)));
        const QModelIndex &groupingNewPosIndexParent = groupingNewPosIndex.parent();

        // Disallow moves within a flattened-out group (TODO: for now, anyway).
        if (groupingRowIndexParent.isValid() && (groupingRowIndexParent == groupingNewPosIndex || groupingRowIndexParent == groupingNewPosIndexParent)) {
            return false;
        }

        int offset = 0;
        int extraChildCount = 0;

        if (groupingRowIndexParent.isValid()) {
            offset = groupingRowIndex.row();
            extraChildCount = d->groupingProxyModel->rowCount(groupingRowIndexParent) - 1;
            groupingRowIndex = groupingRowIndexParent;
        }

        if (groupingNewPosIndexParent.isValid()) {
            int extra = d->groupingProxyModel->rowCount(groupingNewPosIndexParent) - 1;

            if (newPos > row) {
                newPos += extra;
                newPos -= groupingNewPosIndex.row();
                groupingNewPosIndex = groupingNewPosIndexParent.model()->index(extra, 0, groupingNewPosIndexParent);
            } else {
                newPos -= groupingNewPosIndex.row();
                groupingNewPosIndex = groupingNewPosIndexParent;
            }
        }

        beginMoveRows(QModelIndex(), (row - offset), (row - offset) + extraChildCount, QModelIndex(), (newPos > row) ? newPos + 1 : newPos);

        row = d->sortedPreFilterRows.indexOf(d->filterProxyModel->mapToSource(d->groupingProxyModel->mapToSource(groupingRowIndex)).row());
        newPos = d->sortedPreFilterRows.indexOf(d->filterProxyModel->mapToSource(d->groupingProxyModel->mapToSource(groupingNewPosIndex)).row());

        // Update sort mappings.
        d->sortedPreFilterRows.move(row, newPos);

        endMoveRows();

        if (groupingRowIndexParent.isValid()) {
            d->consolidateManualSortMapForGroup(groupingRowIndexParent);
        }

    } else {
        beginMoveRows(parent, row, row, parent, (newPos > row) ? newPos + 1 : newPos);

        // Translate to sort map indices.
        const QModelIndex &groupingRowIndex = mapToSource(index(row, 0, parent));
        const QModelIndex &preFilterRowIndex = d->preFilterIndex(groupingRowIndex);

        const bool groupNotDisabled = !parent.isValid() && groupMode() != GroupDisabled;
        QModelIndex adjacentGroupingRowIndex; // Also consolidate the adjacent group parent
        if (groupNotDisabled) {
            if (newPos > row && row + 1 < rowCount(parent)) {
                adjacentGroupingRowIndex = mapToSource(index(row + 1, 0, parent) /* task on the right */);
            } else if (newPos < row && row - 1 >= 0) {
                adjacentGroupingRowIndex = mapToSource(index(row - 1, 0, parent) /* task on the left */);
            }
        }

        row = d->sortedPreFilterRows.indexOf(preFilterRowIndex.row());
        newPos = d->sortedPreFilterRows.indexOf(d->preFilterIndex(mapToSource(index(newPos, 0, parent))).row());

        // Update sort mapping.
        d->sortedPreFilterRows.move(row, newPos);

        endMoveRows();

        // If we moved a group parent, consolidate sort map for children.
        if (groupNotDisabled) {
            if (d->groupingProxyModel->rowCount(groupingRowIndex)) {
                d->consolidateManualSortMapForGroup(groupingRowIndex);
            }
            // Special case: Before moving, the task at newPos is a group parent
            // Before moving: [Task] [Group parent] [Other task in group]
            // After moving: [Group parent (not consolidated yet)] [Task, newPos] [Other task in group]
            if (int childCount = d->groupingProxyModel->rowCount(adjacentGroupingRowIndex); childCount && adjacentGroupingRowIndex.isValid()) {
                d->consolidateManualSortMapForGroup(adjacentGroupingRowIndex);
                if (newPos > row) {
                    newPos += childCount - 1;
                    // After consolidation: [Group parent (not consolidated yet)] [Other task in group] [Task, newPos]
                }
                // No need to consider newPos < row
                // Before moving: [Group parent, newPos] [Other task in group] [Task]
                // After moving: [Task, newPos] [Group parent] [Other task in group]
            }
        }
    }

    // Resort.
    d->forceResort();

    if (!d->separateLaunchers) {
        if (isLauncherMove) {
            const QModelIndex &idx = d->concatProxyModel->index(d->sortedPreFilterRows.at(newPos), 0);
            const QUrl &launcherUrl = idx.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();

            // Move launcher for launcher-backed task along with task if launchers
            // are not being kept separate.
            // We don't need to resort again because the launcher is implicitly hidden
            // at this time.
            if (!idx.data(AbstractTasksModel::IsLauncher).toBool()) {
                const int launcherPos = d->launcherTasksModel->launcherPosition(launcherUrl);
                const QModelIndex &launcherIndex = d->launcherTasksModel->index(launcherPos, 0);
                const int sortIndex = d->sortedPreFilterRows.indexOf(d->concatProxyModel->mapFromSource(launcherIndex).row());
                d->sortedPreFilterRows.move(sortIndex, newPos);

                if (row > newPos && newPos >= 1) {
                    const QModelIndex beforeIdx = d->concatProxyModel->index(d->sortedPreFilterRows.at(newPos - 1), 0);
                    if (beforeIdx.data(AbstractTasksModel::IsLauncher).toBool()) {
                        // Search forward to skip grouped tasks
                        int afterPos = newPos + 1;
                        for (; afterPos < d->sortedPreFilterRows.size(); ++afterPos) {
                            const QModelIndex tempIdx = d->concatProxyModel->index(d->sortedPreFilterRows.at(afterPos), 0);
                            if (!appsMatch(idx, tempIdx)) {
                                break;
                            }
                        }

                        const QModelIndex afterIdx = d->concatProxyModel->index(d->sortedPreFilterRows.at(afterPos), 0);
                        if (appsMatch(beforeIdx, afterIdx)) {
                            d->sortedPreFilterRows.move(newPos - 1, afterPos - 1);
                        }
                    }
                }
                // Otherwise move matching windows to after the launcher task (they are
                // currently hidden but might be on another virtual desktop).
            } else {
                for (int i = (d->sortedPreFilterRows.count() - 1); i >= 0; --i) {
                    const QModelIndex &concatProxyIndex = d->concatProxyModel->index(d->sortedPreFilterRows.at(i), 0);

                    if (launcherUrl == concatProxyIndex.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl()) {
                        d->sortedPreFilterRows.move(i, newPos);

                        if (newPos > i) {
                            --newPos;
                        }
                    }
                }
            }
        } else if (newPos > 0 && newPos < d->sortedPreFilterRows.size() - 1) {
            /*
             * When dragging an unpinned task, a pinned task can also be moved.
             * In this case, sortedPreFilterRows is like:
             *  - before moving: [pinned 1 (launcher item)] [pinned 1 (window)] [unpinned]
             *  - after moving: [pinned 1 (launcher item)] [unpinned] [pinned 1 (window)]
             * So also check the indexes before and after the unpinned task.
             */
            const QModelIndex beforeIdx = d->concatProxyModel->index(d->sortedPreFilterRows.at(newPos - 1), 0, parent);
            const QModelIndex afterIdx = d->concatProxyModel->index(d->sortedPreFilterRows.at(newPos + 1), 0, parent);
            // BUG 462508: check if any item is a launcher
            const bool hasLauncher = beforeIdx.data(AbstractTasksModel::IsLauncher).toBool() || afterIdx.data(AbstractTasksModel::IsLauncher).toBool();

            if (hasLauncher && appsMatch(beforeIdx, afterIdx)) {
                // after adjusting: [unpinned] [pinned 1 (launcher item)] [pinned 1]
                d->sortedPreFilterRows.move(newPos, newPos + (row < newPos ? 1 : -1));
            }
        }
    }

    // Setup for syncLaunchers().
    d->launcherSortingDirty = isLauncherMove;

    return true;
}

void TasksModel::syncLaunchers()
{
    // Writes the launcher order exposed through the model back to the launcher
    // tasks model, committing any move() operations to persistent state.

    if (!d->launcherTasksModel || !d->launcherSortingDirty) {
        return;
    }

    QMap<int, QString> sortedShownLaunchers;
    QStringList sortedHiddenLaunchers;

    for (const auto launchers = launcherList(); const QString &launcherUrlStr : launchers) {
        int row = -1;
        QList<QStringView> activities;
        QUrl launcherUrl;

        std::tie(launcherUrl, activities) = deserializeLauncher(launcherUrlStr);

        for (int i = 0; i < rowCount(); ++i) {
            const QUrl &rowLauncherUrl = index(i, 0).data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();

            // `LauncherTasksModel::launcherList()` returns data in a format suitable for writing
            // to persistent configuration storage, e.g. `preferred://browser`. We mean to compare
            // this last "save state" to a higher, resolved URL representation to compute the delta
            // so we need to move the unresolved URLs through `TaskTools::appDataFromUrl()` first.
            // TODO: This bypasses an existing lookup cache for the resolved app data that exists
            // in LauncherTasksModel. It's likely a good idea to eventually move these caches out
            // of the various models and share them among users of `TaskTools::appDataFromUrl()`,
            // and then also do resolution implicitly in `TaskTools::launcherUrlsMatch`, to speed
            // things up slightly and make the models simpler (central cache eviction, ...).
            if (launcherUrlsMatch(appDataFromUrl(launcherUrl).url, rowLauncherUrl, IgnoreQueryItems)) {
                row = i;
                break;
            }
        }

        if (row != -1) {
            sortedShownLaunchers.insert(row, launcherUrlStr);
        } else {
            sortedHiddenLaunchers << launcherUrlStr;
        }
    }

    // Prep sort map for source model data changes.
    if (d->sortMode == SortManual) {
        QList<int> sortMapIndices;
        QList<int> preFilterRows;

        for (int i = 0; i < d->launcherTasksModel->rowCount(); ++i) {
            const QModelIndex &launcherIndex = d->launcherTasksModel->index(i, 0);
            const QModelIndex &concatIndex = d->concatProxyModel->mapFromSource(launcherIndex);
            sortMapIndices << d->sortedPreFilterRows.indexOf(concatIndex.row());
            preFilterRows << concatIndex.row();
        }

        // We're going to write back launcher model entries in the sort
        // map in concat model order, matching the reordered launcher list
        // we're about to pass down.
        std::ranges::sort(sortMapIndices);

        for (int i = 0; i < sortMapIndices.count(); ++i) {
            d->sortedPreFilterRows.replace(sortMapIndices.at(i), preFilterRows.at(i));
        }
    }

    setLauncherList(sortedShownLaunchers.values() + sortedHiddenLaunchers);

    // The accepted rows are outdated after the item order is changed
    invalidateFilter();
    d->forceResort();

    d->launcherSortingDirty = false;
}

QModelIndex TasksModel::activeTask() const
{
    for (int i = 0; i < rowCount(); ++i) {
        const QModelIndex &idx = index(i, 0);

        if (idx.data(AbstractTasksModel::IsActive).toBool()) {
            if (groupMode() != GroupDisabled && rowCount(idx)) {
                for (int j = 0; j < rowCount(idx); ++j) {
                    const QModelIndex &child = index(j, 0, idx);

                    if (child.data(AbstractTasksModel::IsActive).toBool()) {
                        return child;
                    }
                }
            } else {
                return idx;
            }
        }
    }

    return {};
}

QModelIndex TasksModel::makeModelIndex(int row, int childRow) const
{
    if (row < 0 || row >= rowCount()) {
        return {};
    }

    if (childRow == -1) {
        return index(row, 0);
    } else {
        const QModelIndex &parent = index(row, 0);

        if (childRow < rowCount(parent)) {
            return index(childRow, 0, parent);
        }
    }

    return {};
}

QPersistentModelIndex TasksModel::makePersistentModelIndex(int row, int childCount) const
{
    return QPersistentModelIndex(makeModelIndex(row, childCount));
}

void TasksModel::classBegin()
{
    d->usedByQml = true;
}

void TasksModel::componentComplete()
{
    d->componentComplete = true;

    // Sets our source model, populating the model.
    d->updateGroupInline();
}

bool TasksModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
    // All our filtering occurs at the top-level; anything below always
    // goes through.
    if (sourceParent.isValid()) {
        return true;
    }

    const QModelIndex &sourceIndex = sourceModel()->index(sourceRow, 0);

    // In inline grouping mode, filter out group parents.
    if (d->groupInline && d->flattenGroupsProxyModel && sourceIndex.data(AbstractTasksModel::IsGroupParent).toBool()) {
        return false;
    }

    const QString &appId = sourceIndex.data(AbstractTasksModel::AppId).toString();
    const QString &appName = sourceIndex.data(AbstractTasksModel::AppName).toString();

    // Filter startup tasks we already have a window task for.
    if (sourceIndex.data(AbstractTasksModel::IsStartup).toBool()) {
        for (int i = 0; i < d->filterProxyModel->rowCount(); ++i) {
            const QModelIndex &filterIndex = d->filterProxyModel->index(i, 0);

            if (!filterIndex.data(AbstractTasksModel::IsWindow).toBool()) {
                continue;
            }

            if ((!appId.isEmpty() && appId == filterIndex.data(AbstractTasksModel::AppId).toString())
                || (!appName.isEmpty() && appName == filterIndex.data(AbstractTasksModel::AppName).toString())) {
                return false;
            }
        }
    }

    // Filter launcher tasks we already have a startup or window task for (that
    // got through filtering).
    if (d->hideActivatedLaunchers && sourceIndex.data(AbstractTasksModel::IsLauncher).toBool()) {
        for (int i = 0; i < d->filterProxyModel->rowCount(); ++i) {
            const QModelIndex &filteredIndex = d->filterProxyModel->index(i, 0);

            if (!filteredIndex.data(AbstractTasksModel::IsWindow).toBool() && !filteredIndex.data(AbstractTasksModel::IsStartup).toBool()) {
                continue;
            }

            if (appsMatch(sourceIndex, filteredIndex)) {
                return false;
            }
        }
    }

    return true;
}

bool TasksModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
    // In manual sort mode, sort by map.
    if (d->sortMode == SortManual) {
        return (d->sortedPreFilterRows.indexOf(d->preFilterIndex(left).row()) < d->sortedPreFilterRows.indexOf(d->preFilterIndex(right).row()));
    }

    return d->lessThan(left, right);
}

std::shared_ptr<VirtualDesktopInfo> TasksModel::virtualDesktopInfo() const
{
    static std::weak_ptr<VirtualDesktopInfo> s_virtualDesktopInfo;
    if (s_virtualDesktopInfo.expired()) {
        auto ptr = std::make_shared<VirtualDesktopInfo>();
        s_virtualDesktopInfo = ptr;
        return ptr;
    }
    return s_virtualDesktopInfo.lock();
}

std::shared_ptr<ActivityInfo> TasksModel::activityInfo() const
{
    static std::weak_ptr<ActivityInfo> s_activityInfo;
    if (s_activityInfo.expired()) {
        auto ptr = std::make_shared<ActivityInfo>();
        s_activityInfo = ptr;
        return ptr;
    }
    return s_activityInfo.lock();
}
}

#include "moc_tasksmodel.cpp"
