// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0

#include "cpuusagemodel.h"

#include <tracing/timelinemodelaggregator.h>

#include <utils/theme/theme.h>
#include <utils/theme/theme_p.h>

#include <QSignalSpy>
#include <QtTest>

using namespace QmlProfiler::Internal;

// The timeline color helpers query Utils::creatorTheme(), which is null in a
// bare unit test. Provide a minimal theme so colorByHue() does not dereference
// a null theme.
class DummyTheme : public Utils::Theme
{
public:
    DummyTheme() : Utils::Theme(QLatin1String("dummy")) {}
};

// Three ticks at t=0, 100, 200 µs with two threads:
//   t=0:   both running (usage 2)
//   t=100: only thread 10 running (usage 1)
//   t=200: none running (usage 0)
static SampleTraceData makeTestData()
{
    SampleTraceData data;
    data.pid = 1;
    data.labels = {"start"};
    data.threadNames = {{10, "main"}, {11, QString()}};
    data.samples = {
        {0, 10, true, {0}},
        {0, 11, true, {0}},
        {100, 10, true, {0}},
        {100, 11, false, {0}},
        {200, 10, false, {0}},
        {200, 11, false, {0}},
    };
    return data;
}

class tst_CpuUsageModel : public QObject
{
    Q_OBJECT

private slots:
    void initTestCase()
    {
        Utils::setCreatorTheme(new DummyTheme);
    }

    void cleanupTestCase()
    {
        delete Utils::creatorTheme();
        Utils::setCreatorTheme(nullptr);
    }

    void rowsAndCounts()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data = makeTestData();
        model.setTraceData(&data);

        // 3 total items + 3 running-thread items (2 at t=0, 1 at t=100).
        QCOMPARE(model.count(), 6);
        // Row 0 is the timeline's category header row.
        QCOMPARE(model.expandedRowCount(), 4); // header + total + 2 threads
        QCOMPARE(model.collapsedRowCount(), 2); // header + total
        // Total row scaled to its peak (2 concurrent threads), labeled per-core: 200%.
        QCOMPARE(model.rowMaxValue(1), qint64(200));
        QCOMPARE(model.traceEndNs(), qint64(200) * 1000);
    }

    void heightsAndRows()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data = makeTestData();
        model.setTraceData(&data);
        model.setExpanded(true);

        int totalItems = 0;
        int threadItems = 0;
        for (int i = 0; i < model.count(); ++i) {
            if (model.expandedRow(i) == 1) {
                ++totalItems;
                // Total height = running / peak(2): t0 -> 2/2, t100 -> 1/2, t200 -> 0.
                if (model.startTime(i) == 0)
                    QCOMPARE(model.relativeHeight(i), 1.0f);
                else if (model.startTime(i) == 100000) // 100 µs in ns
                    QCOMPARE(model.relativeHeight(i), 0.5f);
                else
                    QCOMPARE(model.relativeHeight(i), 0.0f);
                QCOMPARE(model.collapsedRow(i), 1);
            } else {
                ++threadItems;
                QVERIFY(model.expandedRow(i) >= 2);
                QCOMPARE(model.relativeHeight(i), 1.0f); // expanded: full height
            }
        }
        QCOMPARE(totalItems, 3);
        QCOMPARE(threadItems, 3);

        // Collapsed: thread items share the total row and repeat its fraction
        // (running / peak) instead of drawing full-height bars over it.
        model.setExpanded(false);
        for (int i = 0; i < model.count(); ++i) {
            if (model.expandedRow(i) >= 2) {
                QCOMPARE(model.relativeHeight(i),
                         float(model.startTime(i) == 0 ? 1.0f : 0.5f));
            }
        }
    }

    void labelsNameThreads()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data = makeTestData();
        model.setTraceData(&data);

        const Timeline::RowLabels labels = model.labels();
        QCOMPARE(labels.size(), 3); // Total + 2 threads
        QCOMPARE(labels[0].description, QString("Total"));
        QVERIFY2(labels[1].description.contains("main"), qPrintable(labels[1].description));
        QVERIFY2(labels[2].description.contains("11"), qPrintable(labels[2].description));
    }

    // The total row must read as a bar graph: a constant color (heights carry
    // the information) and a row tall enough for the track painter to draw its
    // value-scale axis (>= 60 px).
    void totalRowRendersAsGraph()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data = makeTestData();
        model.setTraceData(&data);

        QRgb totalColor = 0;
        bool seenTotal = false;
        for (int i = 0; i < model.count(); ++i) {
            if (model.expandedRow(i) != 1)
                continue;
            if (!seenTotal) {
                totalColor = model.color(i);
                seenTotal = true;
            } else {
                QCOMPARE(model.color(i), totalColor); // same hue for every load level
            }
        }
        QVERIFY(seenTotal);
        QCOMPARE(model.expandedRowHeight(1), 3 * Timeline::TimelineModel::defaultRowHeight());
    }

    void densityZoomedOutAverages()
    {
        // 5 ticks at 0,100,200,300,400 us. thread 10 on for 3 of them,
        // thread 11 on for 2; peak concurrent running = 2.
        SampleTraceData data;
        data.pid = 1;
        data.labels = {"f"};
        data.threadNames = {{10, "a"}, {11, "b"}};
        data.samples = {
            {0, 10, true, {0}},   {0, 11, true, {0}},    // running 2
            {100, 10, true, {0}}, {100, 11, false, {0}}, // running 1
            {200, 10, true, {0}}, {200, 11, false, {0}}, // running 1
            {300, 10, false, {0}},{300, 11, true, {0}},  // running 1
            {400, 10, false, {0}},{400, 11, false, {0}}, // running 0
        };
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        model.setTraceData(&data);
        QVERIFY(model.rendersAsDensity());

        // One column covering the whole trace [0, 400us] in ns.
        QList<float> col(1, 0.0f);
        // thread 10 row (row 2): on 3 of 5 ticks -> 0.6
        QVERIFY(model.fillDensityColumns(2, 0, qint64(400) * 1000, col));
        QCOMPARE(col[0], 0.6f);
        // thread 11 row (row 3): on 2 of 5 ticks -> 0.4
        col.assign(1, 0.0f);
        QVERIFY(model.fillDensityColumns(3, 0, qint64(400) * 1000, col));
        QCOMPARE(col[0], 0.4f);
        // total row (row 1): avg running (2+1+1+1+0)/5 = 1.0, / maxRunning(2) = 0.5
        col.assign(1, 0.0f);
        QVERIFY(model.fillDensityColumns(1, 0, qint64(400) * 1000, col));
        QCOMPARE(col[0], 0.5f);
    }

    void densityTwoColumnsSplit()
    {
        // thread on for the first half, off for the second.
        SampleTraceData data;
        data.pid = 1;
        data.labels = {"f"};
        data.threadNames = {{10, "a"}};
        data.samples = {
            {0, 10, true, {0}}, {100, 10, true, {0}},
            {200, 10, false, {0}}, {300, 10, false, {0}},
        };
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        model.setTraceData(&data);

        // Two columns over [0, 400us): ticks 0,100 in col 0; 200,300 in col 1.
        QList<float> col(2, 0.0f);
        QVERIFY(model.fillDensityColumns(2, 0, qint64(400) * 1000, col));
        QCOMPARE(col[0], 1.0f); // both first-half ticks running
        QCOMPARE(col[1], 0.0f); // both second-half ticks idle
    }

    void densityZoomedInInheritsTick()
    {
        // More columns than ticks: empty columns inherit the covering tick.
        SampleTraceData data;
        data.pid = 1;
        data.labels = {"f"};
        data.threadNames = {{10, "a"}};
        data.samples = {{0, 10, true, {0}}, {100, 10, false, {0}}};
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        model.setTraceData(&data);

        // 8 columns over [0,100us): early columns map to tick@0 (running),
        // later columns to tick@100 (idle); none left at the uninitialised -1.
        QList<float> col(8, -1.0f);
        QVERIFY(model.fillDensityColumns(2, 0, qint64(100) * 1000, col));
        QCOMPARE(col.first(), 1.0f); // covered by the running tick@0
        QCOMPARE(col.last(), 0.0f);  // covered by the idle tick@100
        for (float v : col)
            QVERIFY(v == 0.0f || v == 1.0f); // never the uninitialised -1
    }

    void densityCacheStableAndInvalidated()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data;
        data.pid = 1;
        data.labels = {"f"};
        data.threadNames = {{10, "a"}};
        data.samples = {{0, 10, true, {0}}, {100, 10, false, {0}}};
        model.setTraceData(&data);

        // Same (range, width) twice -> identical results (cache-hit path).
        QList<float> a(4, 0.0f), b(4, 0.0f);
        QVERIFY(model.fillDensityColumns(2, 0, qint64(100) * 1000, a));
        QVERIFY(model.fillDensityColumns(2, 0, qint64(100) * 1000, b));
        QCOMPARE(a, b);
        QCOMPARE(a.first(), 1.0f); // thread on in the first half
        QCOMPARE(a.last(), 0.0f);  // off in the second half

        // Reloading different data must invalidate the cache: thread 10 never
        // runs now, so every column is 0 (not the stale running values).
        SampleTraceData data2;
        data2.pid = 1;
        data2.labels = {"f"};
        data2.threadNames = {{10, "a"}};
        data2.samples = {{0, 10, false, {0}}, {100, 10, false, {0}}};
        model.setTraceData(&data2);
        QList<float> c(4, -1.0f);
        QVERIFY(model.fillDensityColumns(2, 0, qint64(100) * 1000, c));
        for (float v : c)
            QCOMPARE(v, 0.0f);
    }

    void rowColorCachedPerRow()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data = makeTestData();
        model.setTraceData(&data);
        model.setExpanded(true);

        int totalItem = -1, threadItem = -1, threadRow = -1;
        for (int i = 0; i < model.count(); ++i) {
            if (model.expandedRow(i) == 1) {
                if (totalItem < 0)
                    totalItem = i;
            } else if (model.expandedRow(i) >= 2 && threadItem < 0) {
                threadItem = i;
                threadRow = model.expandedRow(i);
            }
        }
        QVERIFY(totalItem >= 0);
        QVERIFY(threadItem >= 0);
        QCOMPARE(model.rowColor(1), model.color(totalItem));          // total row
        QCOMPARE(model.rowColor(threadRow), model.color(threadItem)); // a thread row
        QCOMPARE(model.rowColor(999), QRgb(0xff808080));              // out of range -> grey
    }

    void selectedEventShowsStack()
    {
        SampleTraceData data;
        data.pid = 1;
        data.labels = {"start", "main", "work"}; // frames index into these (root-first)
        data.threadNames = {{10, "worker"}};
        data.samples = {{0, 10, true, {0, 1, 2}}}; // start>main>work; leaf = work
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        model.setTraceData(&data);

        int threadItem = -1, totalItem = -1;
        for (int i = 0; i < model.count(); ++i) {
            if (model.expandedRow(i) == 1 && totalItem < 0)
                totalItem = i;
            else if (model.expandedRow(i) >= 2 && threadItem < 0)
                threadItem = i;
        }
        QVERIFY(threadItem >= 0);
        QVERIFY(totalItem >= 0);

        const Timeline::OrderedItemDetails od = model.orderedDetails(threadItem);
        QCOMPARE(od.title, QString("worker"));
        QCOMPARE(od.content.size(), 3);
        QCOMPARE(od.content[0].second, QString("work"));
        QCOMPARE(od.content[1].second, QString("main"));
        QCOMPARE(od.content[2].second, QString("start"));

        const Timeline::OrderedItemDetails tod = model.orderedDetails(totalItem);
        QCOMPARE(tod.title, QString("CPU Usage"));
        bool listsWorker = false;
        for (const auto &row : tod.content)
            if (row.first == QString("worker") || row.second == QString("worker"))
                listsWorker = true;
        QVERIFY(listsWorker);
    }

    void doubleClickFrameEmitsGotoSource()
    {
        SampleTraceData data;
        data.pid = 1;
        SampleTraceData::Label start("start");
        SampleTraceData::Label mainFn("main");
        SampleTraceData::Label work("work");
        work.file = "/src/work.cpp";
        work.line = 42;
        work.module = "libwork";
        work.offset = 0x1000;
        data.labels = {start, mainFn, work};
        data.threadNames = {{10, "worker"}};
        data.samples = {{0, 10, true, {0, 1, 2}}}; // start>main>work; leaf = work
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        model.setTraceData(&data);

        int threadItem = -1, totalItem = -1;
        for (int i = 0; i < model.count(); ++i) {
            if (model.expandedRow(i) == 1 && totalItem < 0)
                totalItem = i;
            else if (model.expandedRow(i) >= 2 && threadItem < 0)
                threadItem = i;
        }
        QVERIFY(threadItem >= 0);
        QVERIFY(totalItem >= 0);

        QSignalSpy spy(&model, &Timeline::TimelineModel::gotoSourceLocation);

        // Detail row 0 = leaf = "work" (has a source location).
        model.navigateToDetail(threadItem, 0);
        QCOMPARE(spy.count(), 1);
        const QList<QVariant> args = spy.takeFirst();
        QCOMPARE(args.at(0).toString(), QString("/src/work.cpp"));
        QCOMPARE(args.at(1).toInt(), 42);
        QCOMPARE(args.at(2).toInt(), 0);
        QCOMPARE(args.at(3).toString(), QString("libwork"));
        QCOMPARE(args.at(4).value<quint64>(), quint64(0x1000));

        // Inner frame "main" has no location -> no emission.
        model.navigateToDetail(threadItem, 1);
        QCOMPARE(spy.count(), 0);

        // Total bar, out-of-range row, out-of-range item -> nothing, no crash.
        model.navigateToDetail(totalItem, 0);
        model.navigateToDetail(threadItem, 99);
        model.navigateToDetail(9999, 0);
        QCOMPARE(spy.count(), 0);
    }

    void detailsToleratesStaleIndex()
    {
        // A selection index that was valid before the trace was unloaded can
        // still reach the detail accessors: TimelineModel::clear() emits
        // expandedChanged while its range count is still stale, so the timeline
        // re-shows the old selection (rebuildTracks -> showItemDetails) against
        // the now-empty model. The accessors must not index out of bounds.
        SampleTraceData data;
        data.pid = 1;
        data.labels = {"start", "main", "work"};
        data.threadNames = {{10, "worker"}};
        data.samples = {{0, 10, true, {0, 1, 2}}};
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        model.setTraceData(&data);
        const int staleIndex = model.count() - 1;
        QVERIFY(staleIndex >= 0);

        model.setTraceData(nullptr);
        QCOMPARE(model.count(), 0);

        const Timeline::OrderedItemDetails od = model.orderedDetails(staleIndex);
        QVERIFY(od.title.isEmpty());
        QVERIFY(od.content.isEmpty());
        QVERIFY(model.details(staleIndex).isEmpty());
    }

    void clearEmptiesModel()
    {
        Timeline::TimelineModelAggregator aggregator;
        CpuUsageModel model(&aggregator);
        SampleTraceData data = makeTestData();
        model.setTraceData(&data);
        model.setTraceData(nullptr);
        QCOMPARE(model.count(), 0);
        QCOMPARE(model.traceEndNs(), qint64(0));
    }
};

QTEST_GUILESS_MAIN(tst_CpuUsageModel)

#include "tst_cpuusagemodel.moc"
