// 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 "qtprofilerwindow.h"

#include <profiler/callstacksampler.h>
#include <profiler/combinedsampler.h>
#include <profiler/combinedtraceloader.h>
#include "mainsidebar.h"
#include <profiler/perfsampler.h>
#include "qtprofilerrpc.h"
#include "qtprofilersettings.h"
#include "recordingpage.h"
#include <profiler/qmlprofilersampler.h>
#include <profiler/sampler.h>
#include <profiler/samplerviewmanager.h>
#include "welcomepage.h"

#include <utils/aspects.h>
#include <utils/layoutbuilder.h>

#include <profiler/ctfplainviewmanager.h>
#include <profiler/profilertr.h>
#include <profiler/sampletrace.h>
#include <profiler/qmlprofilerconstants.h>
#include <profiler/qmlprofilerplainviewmanager.h>

#include <coreplugin/minisplitter.h>

#include <tracing/rangedetailswidget.h>

#include <utils/commandline.h>
#include <utils/environment.h>
#include <utils/fancymainwindow.h>
#include <utils/fileutils.h>
#include <utils/guiutils.h>
#include <utils/hostosinfo.h>
#include <utils/progressindicator.h>
#include <utils/qtcprocess.h>
#include <utils/stylehelper.h>
#include <utils/utilsicons.h>
#include <utils/widgets.h>

#include <QtTaskTree/QBarrier>
#include <QtTaskTree/QSingleTaskTreeRunner>

#include <QApplication>
#include <QDesktopServices>
#include <QDir>
#include <QDockWidget>
#include <QEventLoop>
#include <QLabel>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QRegularExpression>
#include <QSplitter>
#include <QStackedWidget>
#include <QTime>
#include <QTimer>
#include <QToolBar>
#include <QToolButton>
#include <QUrl>

#include <algorithm>
#include <atomic>
#include <iostream>
#include <optional>
#include <memory>

using namespace Profiler;
using namespace QmlProfiler::Internal;
using namespace Utils;
using namespace Utils::StyleHelper;

using namespace Qt::StringLiterals;

using namespace std::chrono;

namespace QtProfiler {

// A trace file format determines which manager and view set are used. Chrome
// Trace Format and Common Trace Format both render through the CTF views.
// Combined shows the QML profiler views and the sampler views together, for a
// combined recording (native-mixed sampler trace + its source QML trace).
enum class Format { Qml, Ctf, Sampler, Combined };

// pkexec's exit code for "the user dismissed the authentication dialog".
constexpr int pkexecDismissed = 126;

// One set of dock widgets. The views are tabbed onto the first by default; when
// stackVertically is set they are split below it instead (the sampler stacks
// Call Stacks below CPU Usage). The range details dock is not part of a group:
// every backend fills the same one (see WindowPrivate::rangeDetails).
struct ViewGroup
{
    QList<QDockWidget *> docks;         // List with original dock widgets order
    bool stackVertically = false;
    bool built = false;                 // Views created and docked lazily on first use
};

class WindowPrivate : public QObject
{
public:
    WindowPrivate(Window *window = nullptr);

    void showOpenFileDialog();
    void showOpenCtfDirDialog();
    void selectBackend(int index);
    bool selectBackendByName(const QString &name);
    void startRecording();
    void startTimedRecording(milliseconds duration);
    void beginRecording(const QString &displayName);
    void finishRecording();
    void stopAndWaitForRecording();

    void onError(const QString &error); // Connected to the loaders' failure signals.
    void showError(const QString &error, const std::optional<SamplerFix> &fix);
    void applyFix(const SamplerFix &fix);
    void onLoadFinished();
    void onGotoSourceLocation(const QString &fileUrl, int lineNumber, int columnNumber,
                              const QString &module = {}, quint64 offset = 0);

    ViewGroup &groupFor(Format format);
    QList<ViewGroup *> groupsFor(Format format);
    void ensureGroupBuilt(Format format);
    void resetLayout(Format format);
    void resetActiveLayout();
    void setActiveFormat(Format format);
    void clearTrace();
    void doLoad(const Utils::FilePath &filePath);
    void setTraceDuration(milliseconds ms);
    milliseconds activeTraceDuration() const;

    static void openHelpInBrowser();

    Window *q = nullptr;
    QStackedWidget *rightPane = nullptr;  // Welcome / recording page or trace area.
    WelcomePage *welcomePage = nullptr;   // Shown while no trace is loaded.
    RecordingPage *recordingPage = nullptr; // Shown while a recording is running.
    FancyMainWindow *traceArea = nullptr; // Hosts the trace view docks (right pane).
    MainSidebar *sidebar = nullptr;       // List of opened traces (left pane).
    // The one range details panel every backend's views fill, and its dock. It is
    // shared rather than per-backend so a trace shows a single "Details" view --
    // combined traces would otherwise put the QML and sampler ones side by side.
    Timeline::RangeDetailsWidget *rangeDetails = nullptr;
    QDockWidget *detailsDock = nullptr;   // Created with the first view group.
    QmlProfilerPlainViewManager *qmlManager;
    CtfPlainViewManager *ctfManager;
    SamplerViewManager *samplerManager;
    CombinedTraceLoader *combinedLoader; // Merges a combined bundle into one sampler trace.
    Format activeFormat = Format::Qml;
    QString lastLoadError;
    QLabel *traceDurationLabel = nullptr;
    ProgressIndicator *progressIndicator;
    ViewGroup qmlGroup;
    ViewGroup ctfGroup;
    ViewGroup samplerGroup;
    bool recording = false;
    bool processingShown = false;                 // setProcessing() already called this session.
    bool closing = false;                         // Set while waiting for shutdown.
    std::vector<std::unique_ptr<Sampler>> backends; // Available profiling backends.
    std::vector<int> offeredBackends;             // Indices into `backends` that are actually
                                                   // usable here (see isAvailable()); this is
                                                   // what the dropdown offers, in display order.
    Sampler *sampler = nullptr;                   // The selected backend (owned by `backends`).
    std::shared_ptr<RecordingSession> session;    // Non-null while recording.
    QtTaskTree::QSingleTaskTreeRunner recordingRunner;
    QTimer *processingPoll = nullptr;
    std::optional<milliseconds> recordDuration;   // Set by --record-for; auto-stop span.
    bool recordDurationArmed = false;             // Stop timer armed once capture started.
    int downloadPolls = 0;                        // Consecutive polls seeing a download.
    static constexpr int downloadPollsBeforeReporting = 6; // ~300 ms at a 50 ms poll.
};

// The host names behind a whitespace-separated URL list, for the recording page's
// status line. What perfparser reports is either one request URL, whose build-id
// path says nothing a user needs, or the whole DEBUGINFOD_URLS list from before a
// request was made -- in both cases the host is the part that identifies who is
// being waited on, and the only part short enough for one line.
static QString debugInfoServerNames(const QString &urls)
{
    static const QRegularExpression whitespace("\\s+"_L1);
    QStringList hosts;
    const QStringList entries = urls.split(whitespace, Qt::SkipEmptyParts);
    for (const QString &entry : entries) {
        const QString host = QUrl(entry).host();
        hosts << (host.isEmpty() ? entry : host);
    }
    hosts.removeDuplicates();
    return hosts.join(", "_L1);
}

WindowPrivate::WindowPrivate(Window *window)
    : QObject(window)
    , q(window)
{
    traceArea = new FancyMainWindow(q);
    traceArea->setDockNestingEnabled(true);
    traceArea->setDocumentMode(true);

    sidebar = new MainSidebar(q);

    welcomePage = new WelcomePage(q);
    recordingPage = new RecordingPage(q);

    rightPane = new QStackedWidget(q);
    rightPane->addWidget(welcomePage);
    rightPane->addWidget(recordingPage);
    rightPane->addWidget(traceArea);
    rightPane->setCurrentWidget(welcomePage);

    connect(welcomePage, &WelcomePage::startRecordingRequested,
            this, &WindowPrivate::startRecording);
    connect(welcomePage, &WelcomePage::backendChanged, this, &WindowPrivate::selectBackend);

    // The native call-stack samplers first (macOS mach-based, Linux perf-based),
    // then the QML-protocol profiler. Each backend persists and renders its own
    // settings, and decides how to start (launch / attach / connect) from those
    // settings in createSession().
    backends.push_back(std::make_unique<CallStackSampler>());
    backends.push_back(std::make_unique<PerfSampler>());
    backends.push_back(std::make_unique<QmlProfilerSampler>());
    // Records a native sampler and the QML profiler against one target at once,
    // producing a bundle both are later merged from
    // (see design-docs/native-mixed-profiler-design.md).
    backends.push_back(std::make_unique<CombinedSampler>());

    for (const std::unique_ptr<Sampler> &backend : backends) {
        if (SamplerSettings *s = backend->settings())
            s->readSettings();
    }

    // Only offer backends that are actually usable here -- e.g. CallStackSampler
    // doesn't even appear outside macOS. If that leaves nothing (shouldn't
    // happen; QmlProfilerSampler is always available), fall back to offering
    // everything so the dropdown isn't empty and Start Recording can still
    // explain why.
    for (int i = 0; i < int(backends.size()); ++i) {
        if (backends[i]->isAvailable())
            offeredBackends.push_back(i);
    }
    if (offeredBackends.empty()) {
        for (int i = 0; i < int(backends.size()); ++i)
            offeredBackends.push_back(i);
    }

    QStringList backendNames;
    for (int index : offeredBackends)
        backendNames << backends[index]->displayName();
    welcomePage->setBackends(backendNames, 0);
    // selectBackend() seeds any CLI --launch command into the chosen backend, so
    // this also primes backend 0 for a straight-away start.
    selectBackend(0);

    processingPoll = new QTimer(this);
    processingPoll->setInterval(50);
    connect(processingPoll, &QTimer::timeout, this, [this] {
        if (!session)
            return;
        // The recipe (or the user) may set stop on its own (e.g. the target exited);
        // reflect the switch to post-processing exactly once.
        if (session->stop.load(std::memory_order_relaxed) && !processingShown) {
            processingShown = true;
            recordingPage->setProcessing();
        }
        // For --record-for: once capture is actually live, start the span clock
        // (exactly once) so launch/connect time isn't counted against it.
        if (recordDuration && !recordDurationArmed
                && session->started.load(std::memory_order_relaxed)) {
            recordDurationArmed = true;
            QTimer::singleShot(*recordDuration, this, [this] {
                if (session)
                    session->stop.store(true);
            });
        }
        recordingPage->setProgress(session->progress.load(std::memory_order_relaxed));

        // A debug-info download holds up post-processing without moving the
        // progress bar, for as long as the server takes to answer -- which may be
        // forever. Name it, so the wait is at least attributable (the Perf
        // backend's "Download missing debug information" setting turns it off).
        // A query answered from this machine passes through the same state in
        // microseconds, so only report one that has lasted long enough to be a
        // wait somebody is sitting through.
        const RecordingSession::DebugInfoDownload download = session->debugInfoDownload();
        if (download.percent < 0) {
            downloadPolls = 0;
            recordingPage->setStatus({});
        } else if (++downloadPolls >= downloadPollsBeforeReporting) {
            const QString server = debugInfoServerNames(download.url);
            QString text;
            if (server.isEmpty()) {
                text = download.percent == 0
                           ? Tr::tr("Downloading debug information...")
                           : Tr::tr("Downloading debug information... %1%").arg(download.percent);
            } else {
                text = download.percent == 0
                           ? Tr::tr("Downloading debug information from %1...").arg(server)
                           : Tr::tr("Downloading debug information from %1... %2%")
                                 .arg(server).arg(download.percent);
            }
            // The line names the servers; the whole URL, which carries a build id
            // long enough to crowd everything else out, goes in the tool tip.
            recordingPage->setStatus(text, download.url);
        }
    });

    connect(recordingPage, &RecordingPage::stopRequested, this, [this] {
        if (session)
            session->stop.store(true);
    });

    rangeDetails = new Timeline::RangeDetailsWidget(traceArea);
    qmlManager = new QmlProfilerPlainViewManager(rangeDetails, traceArea);
    ctfManager = new CtfPlainViewManager(rangeDetails, traceArea);
    samplerManager = new SamplerViewManager(rangeDetails, traceArea);

    progressIndicator = new ProgressIndicator(ProgressIndicatorSize::Large);
    progressIndicator->hide();

    connect(sidebar, &MainSidebar::traceActivated, this, &WindowPrivate::doLoad);

    connect(qmlManager, &QmlProfilerPlainViewManager::error, this, &WindowPrivate::onError);
    connect(qmlManager, &QmlProfilerPlainViewManager::loadFinished,
            this, &WindowPrivate::onLoadFinished);
    connect(qmlManager, &QmlProfilerPlainViewManager::gotoSourceLocation, this,
            [this](const QString &file, int line, int column) {
                onGotoSourceLocation(file, line, column);
            });

    connect(ctfManager, &CtfPlainViewManager::error, this, &WindowPrivate::onError);
    connect(ctfManager, &CtfPlainViewManager::loadFinished, this, &WindowPrivate::onLoadFinished);

    connect(samplerManager, &SamplerViewManager::error, this, &WindowPrivate::onError);
    connect(samplerManager, &SamplerViewManager::loadFinished,
            this, &WindowPrivate::onLoadFinished);
    connect(samplerManager, &SamplerViewManager::gotoSourceLocation,
            this, &WindowPrivate::onGotoSourceLocation);

    // A combined bundle is turned into one native-mixed sampler trace off the GUI
    // thread; when it is ready, show it through the sampler views.
    combinedLoader = new CombinedTraceLoader(this);
    connect(combinedLoader, &CombinedTraceLoader::merged, this, [this](const FilePath &dir) {
        // The QML views were already populated in doLoad; fill the sampler views
        // with the merged native-mixed trace. The combined layout is already set.
        setActiveFormat(Format::Combined);
        samplerManager->load(dir);
    });
    connect(combinedLoader, &CombinedTraceLoader::failed, this, &WindowPrivate::onError);
}

void WindowPrivate::showOpenFileDialog()
{
    // "metadata" matches a Common Trace Format metadata file, which selects the
    // containing directory as the CTF2 trace (see loadTraceFile).
    const QString filter = Tr::tr("Traces (*.qtd *.qzt *.json metadata)") + ";;"_L1
                           + Tr::tr("QML Trace Files (*.qtd *.qzt)") + ";;"_L1
                           + Tr::tr("Chrome Trace Format Files (*.json)") + ";;"_L1
                           + Tr::tr("Common Trace Format Metadata Files (metadata)") + ";;"_L1
                           + Tr::tr("All Files (*)");
    // Asynchronous (non-blocking) so it works on platforms where the calling thread
    // must not block in a nested event loop, e.g. WebAssembly without asyncify.
    FileUtils::getOpenFilePathAsync(Tr::tr("Load Trace"), settings().lastTraceFile(), filter)
        .then(q, [this](const FilePath &filePath) {
            if (!filePath.isEmpty())
                q->loadTraceFile(filePath);
        });
}

void WindowPrivate::showOpenCtfDirDialog()
{
    const FilePath dir = FileUtils::getExistingDirectory(
        Tr::tr("Load Common Trace Format Directory"), settings().lastTraceFile().parentDir());

    if (!dir.isEmpty())
        q->loadTraceFile(dir);
}

void WindowPrivate::selectBackend(int index)
{
    if (index < 0 || index >= int(offeredBackends.size()))
        return;
    sampler = backends[offeredBackends[index]].get();

    // A launch command passed on the CLI (--launch) seeds the selected backend so
    // it can be started straight away -- and so --backend can precede or follow
    // --launch. Only fill an empty executable; never clobber a GUI edit.
    if (SamplerSettings *s = sampler->settings()) {
        if (const FilePath exe = settings().recordExecutable();
            !exe.isEmpty() && s->executable().isEmpty()) {
            s->executable.setValue(exe);
            s->arguments.setValue(settings().recordArguments());
        }
    }

    QWidget *config = nullptr;
    if (SamplerSettings *s = sampler->settings()) {
        config = new QWidget;
        s->layouter()().attachTo(config);
    }
    welcomePage->setActiveBackend(config);
}

bool WindowPrivate::selectBackendByName(const QString &name)
{
    for (int i = 0; i < int(offeredBackends.size()); ++i) {
        if (backends[offeredBackends[i]]->displayName().contains(name, Qt::CaseInsensitive)) {
            welcomePage->setCurrentBackend(i); // emits backendChanged() -> selectBackend()
            return true;
        }
    }
    return false;
}

void WindowPrivate::startTimedRecording(milliseconds duration)
{
    startRecording();
    if (!recording)
        return; // startRecording() reported the error already.

    // The clock only starts once the backend is actually capturing (after launch
    // and, for the QML profiler, after the debug connection is up); otherwise the
    // launch/connect delay would eat into the requested span. The processing poll
    // watches session->started and arms the stop timer on the 0->1 transition.
    recordDuration = duration;
    recordDurationArmed = false;
}

void WindowPrivate::startRecording()
{
    if (recording)
        return;

    QString reason;
    if (!sampler->isAvailable(&reason)) {
        onError(reason);
        return;
    }

    SamplerSettings *settings = sampler->settings();
    if (!settings) {
        onError(Tr::tr("This backend cannot be configured for recording."));
        return;
    }

    const Result<std::shared_ptr<RecordingSession>> created = settings->createSession();
    if (!created) {
        onError(created.error());
        return;
    }
    session = *created;

    // A name for the recording page: the launched command, the attach target, or
    // the connect endpoint.
    QString name;
    if (session->launchCommand)
        name = session->launchCommand->executable().fileName();
    else if (!session->processName.isEmpty())
        name = session->processName;
    else if (!session->serverUrl.isEmpty())
        name = session->serverUrl.host() + u':' + QString::number(session->serverUrl.port());
    beginRecording(name);
}

void WindowPrivate::beginRecording(const QString &displayName)
{
    recording = true;
    processingShown = false;
    downloadPolls = 0;
    recordingPage->start(displayName);
    rightPane->setCurrentWidget(recordingPage);
    processingPoll->start();

    // The backend owns the complete recipe: it launches the target (when
    // session->launchCommand is set), captures it, and tears it down. When the
    // tree is done, finishRecording() loads the captured trace.
    recordingRunner.start(QtTaskTree::Group{sampler->recordRecipe(session)}, [] {},
                          [this](QtTaskTree::DoneWith) { finishRecording(); });
}

void WindowPrivate::finishRecording()
{
    recording = false;
    processingPoll->stop();
    recordingPage->stop();
    recordDuration.reset(); // one-shot: don't auto-stop a later manual recording
    recordDurationArmed = false;

    const std::shared_ptr<RecordingSession> finished = session;
    session.reset();

    if (closing)
        return; // The window is shutting down: don't touch the UI or load a trace.

    if (!finished || !finished->result) {
        rightPane->setCurrentWidget(welcomePage);
        onError(Tr::tr("Recording did not produce a trace."));
        return;
    }
    const Result<FilePath> &result = *finished->result;
    if (!result) {
        rightPane->setCurrentWidget(welcomePage);
        showError(result.error(), sampler ? sampler->availableFix() : std::nullopt);
        return;
    }
    q->loadTraceFile(*result);
}

void WindowPrivate::stopAndWaitForRecording()
{
    if (!recording || !session)
        return;
    // The sampler loops until stopped; ask it to finish and pump events until the
    // task tree (and its worker thread) have wound down. Otherwise the global
    // thread pool would block at exit and the launched process would be left
    // running.
    closing = true;
    session->stop.store(true);
    while (recording)
        QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}

void WindowPrivate::onError(const QString &error)
{
    showError(error, std::nullopt);
}

void WindowPrivate::showError(const QString &error, const std::optional<SamplerFix> &fix)
{
    lastLoadError = error;
    progressIndicator->hide();

    if (settings().exitOnError()) {
        std::cerr << lastLoadError.toStdString() << std::endl;
        // Give time for further error notifications.
        QTimer::singleShot(0, []{ QApplication::exit(1); });
    }

    // Non-modal: the static QMessageBox::critical() spins a nested event loop via exec(),
    // which aborts on Qt for WebAssembly (no asyncify). AsynchronousMessageBox does not block.
    if (!fix) {
        AsynchronousMessageBox::critical(Tr::tr("Error"), error);
        return;
    }

    // The backend offered a system setting the user can change to make recording
    // work, so put it one click away instead of leaving them the command to run.
    // Same non-blocking box AsynchronousMessageBox builds, assembled here because
    // a QMessageBox takes its size from the content it has when it is shown: the
    // extra text and button have to be in place before that, or they are laid out
    // into a box already sized for less.
    // Cancel rather than OK: with the fix on offer this asks a question, and the
    // other answer is to leave the setting alone. It stays the default -- Enter
    // should not be a way to walk into a password prompt.
    auto *messageBox = new QMessageBox(QMessageBox::Critical, Tr::tr("Error"), error,
                                       QMessageBox::Cancel, Utils::dialogParent());
    messageBox->setAttribute(Qt::WA_DeleteOnClose);
    messageBox->setModal(true);
    messageBox->setInformativeText(fix->detail);
    QPushButton *fixButton = messageBox->addButton(fix->buttonText, QMessageBox::ActionRole);
    messageBox->setDefaultButton(QMessageBox::Cancel);
    connect(fixButton, &QPushButton::clicked, this, [this, fix = *fix] { applyFix(fix); });
    messageBox->show();
}

void WindowPrivate::applyFix(const SamplerFix &fix)
{
    // pkexec prompts for the password in its own graphical dialog, the only
    // elevation that works from a GUI with no terminal attached; plain sudo would
    // read from a tty that is not there.
    const FilePath pkexec = Environment::systemEnvironment().searchInPath("pkexec");
    if (pkexec.isEmpty()) {
        AsynchronousMessageBox::warning(
            Tr::tr("Cannot Elevate Privileges"),
            Tr::tr("\"pkexec\" was not found, so the change cannot be applied from here. "
                   "Run this as root instead:\n\n    sudo %1")
                .arg(fix.command.toUserOutput()));
        return;
    }

    auto *process = new Process(this);
    CommandLine elevated(pkexec);
    elevated.addCommandLineAsArgs(fix.command);
    process->setCommand(elevated);
    connect(process, &Process::done, this, [this, process, fix] {
        process->deleteLater();
        // The setting is live now, so go straight back to the recording the user
        // asked for; starting it again is the only confirmation they need.
        if (process->result() == ProcessResult::FinishedWithSuccess) {
            startRecording();
            return;
        }
        // Dismissing the prompt is a deliberate "no", not a fault worth an error
        // box. A wrong password exits 127 instead and does get reported, with
        // pkexec's own "Not authorized" on stderr saying so.
        if (process->exitCode() == pkexecDismissed)
            return;
        const QString details = process->cleanedStdErr().trimmed();
        AsynchronousMessageBox::critical(
            Tr::tr("Could Not Apply Change"),
            details.isEmpty() ? Tr::tr("Running \"%1\" failed.").arg(fix.command.toUserOutput())
                              : Tr::tr("Running \"%1\" failed:\n\n%2")
                                    .arg(fix.command.toUserOutput(), details));
    });
    process->start();
}

void WindowPrivate::onLoadFinished()
{
    setTraceDuration(activeTraceDuration());
    progressIndicator->hide();
    RPC::notifyTraceFileLoadingFinished(settings().lastTraceFile(), lastLoadError);
}

void WindowPrivate::onGotoSourceLocation(const QString &fileUrl, int lineNumber, int columnNumber,
                                         const QString &module, quint64 offset)
{
    RPC::notifyTraceEventSelected(fileUrl, lineNumber, columnNumber, module, offset);
}

milliseconds WindowPrivate::activeTraceDuration() const
{
    switch (activeFormat) {
    case Format::Qml: return qmlManager->traceDuration();
    case Format::Ctf: return ctfManager->traceDuration();
    case Format::Sampler: return samplerManager->traceDuration();
    case Format::Combined:
        return qMax(qmlManager->traceDuration(), samplerManager->traceDuration());
    }
    Q_UNREACHABLE_RETURN(milliseconds{0});
}

ViewGroup &WindowPrivate::groupFor(Format format)
{
    switch (format) {
    case Format::Qml: return qmlGroup;
    case Format::Ctf: return ctfGroup;
    case Format::Sampler: return samplerGroup;
    case Format::Combined: break; // Combined spans several groups; use groupsFor().
    }
    Q_UNREACHABLE_RETURN(qmlGroup);
}

// Combined shows the QML and sampler views together; every other format maps to
// a single group. All group-level operations iterate over this list.
QList<ViewGroup *> WindowPrivate::groupsFor(Format format)
{
    switch (format) {
    case Format::Qml: return {&qmlGroup};
    case Format::Ctf: return {&ctfGroup};
    case Format::Sampler: return {&samplerGroup};
    case Format::Combined: return {&qmlGroup, &samplerGroup};
    }
    Q_UNREACHABLE_RETURN({});
}

// A format's views are created and docked the first time that format is shown,
// not up front for every backend. This keeps a trace's tab context menu limited
// to the views that actually apply to it, instead of listing every backend's
// "CPU Usage", "Statistics", etc. (many of them duplicated).
void WindowPrivate::ensureGroupBuilt(Format format)
{
    // Combined has no group of its own; build the groups it shows instead.
    if (format == Format::Combined) {
        ensureGroupBuilt(Format::Qml);
        ensureGroupBuilt(Format::Sampler);
        return;
    }

    // The details panel is shared by all backends, so it gets a single dock, put
    // up with the first views to be built.
    if (!detailsDock)
        detailsDock = traceArea->addDockForWidget(rangeDetails);

    ViewGroup &group = groupFor(format);
    if (group.built)
        return;
    group.built = true;

    const auto buildGroup = [this](const QWidgetList &views, ViewGroup &g) {
        for (QWidget *w : views)
            g.docks.append(traceArea->addDockForWidget(w));
    };

    switch (format) {
    case Format::Qml:
        buildGroup(qmlManager->views(traceArea), qmlGroup);
        break;
    case Format::Ctf:
        buildGroup(ctfManager->views(traceArea), ctfGroup);
        break;
    case Format::Sampler:
        buildGroup(samplerManager->views(traceArea), samplerGroup);
        // Stack Call Stacks below CPU Usage instead of tabbing them together.
        samplerGroup.stackVertically = true;
        break;
    case Format::Combined:
        break; // Handled above by building the QML and sampler groups.
    }
}

void WindowPrivate::resetLayout(Format format)
{
    ensureGroupBuilt(format);

    // The groups shown for each format. Combined tabs the QML and sampler view
    // docks together; both feed the one details dock on the right.
    const QList<ViewGroup *> active = groupsFor(format);

    // Setting "managed_dockwidget" excludes a dock from FancyMainWindow's tab
    // context menu (see FancyMainWindow::addDockActionsToMenu). We hide the other
    // formats' docks and take them out of the menu so a trace only offers the
    // views that apply to it. The details dock applies to every format and so
    // always stays visible and in the menu.
    const auto setInMenu = [](const ViewGroup &group, bool inMenu) {
        const QVariant managed = inMenu ? QVariant() : QVariant("true");
        for (QDockWidget *dw : group.docks)
            dw->setProperty("managed_dockwidget", managed);
    };

    // Hide the docks of every group that is not active, and keep only the active
    // groups' docks in the tab context menu.
    for (ViewGroup *group : {&qmlGroup, &ctfGroup, &samplerGroup}) {
        const bool isActive = active.contains(group);
        if (!isActive) {
            for (QDockWidget *dw : std::as_const(group->docks))
                dw->setVisible(false);
        }
        setInMenu(*group, isActive);
    }

    // A single group may stack its views vertically (the sampler stacks Call
    // Stacks below CPU Usage); when several groups are combined, tab everything.
    const bool stackVertically = active.size() == 1 && active.first()->stackVertically;

    QDockWidget *firstDockWidget = nullptr;
    for (ViewGroup *group : std::as_const(active)) {
        for (QDockWidget *dw : std::as_const(group->docks)) {
            dw->setVisible(true);
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
            dw->setDockLocation(Qt::TopDockWidgetArea);
#endif
            dw->setFloating(false);
            if (!firstDockWidget)
                firstDockWidget = dw;
        }
    }
    QTC_ASSERT(firstDockWidget, return);

    // Split the details off the first view before tabbing the others onto it.
    // QMainWindow::splitDockWidget() only splits when the anchor is not yet tabbed;
    // doing this after tabifying would just add the details as another tab.
    detailsDock->setVisible(true);
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
    detailsDock->setDockLocation(Qt::RightDockWidgetArea);
#endif
    detailsDock->setFloating(false);
    traceArea->splitDockWidget(firstDockWidget, detailsDock, Qt::Horizontal);

    for (ViewGroup *group : std::as_const(active)) {
        for (QDockWidget *dw : std::as_const(group->docks)) {
            if (dw == firstDockWidget)
                continue;
            if (stackVertically)
                traceArea->splitDockWidget(firstDockWidget, dw, Qt::Vertical);
            else
                traceArea->tabifyDockWidget(firstDockWidget, dw);
        }
    }
    firstDockWidget->raise();
}

void WindowPrivate::setActiveFormat(Format format)
{
    // Re-lay out when the format changes, or when this format's views are shown
    // for the first time (they are built lazily, so the initial layout of a
    // format that happens to already be the active one still needs doing).
    const QList<ViewGroup *> groups = groupsFor(format);
    const bool firstShow = std::any_of(groups.cbegin(), groups.cend(),
                                       [](const ViewGroup *g) { return !g->built; });
    if (activeFormat == format && !firstShow)
        return;
    activeFormat = format;
    resetLayout(format);
}

void WindowPrivate::resetActiveLayout()
{
    // Nothing to lay out until the first trace has built its views.
    const QList<ViewGroup *> groups = groupsFor(activeFormat);
    const bool built = std::all_of(groups.cbegin(), groups.cend(),
                                   [](const ViewGroup *g) { return g->built; });
    if (built)
        resetLayout(activeFormat);
}

void WindowPrivate::clearTrace()
{
    // Removing the current trace selects a neighbour, which reloads it via
    // traceActivated(). Only when nothing remains do we clear and show the
    // welcome page.
    if (sidebar->removeCurrentTrace())
        return;

    setTraceDuration(milliseconds{0});
    combinedLoader->cancel();
    qmlManager->clear();
    ctfManager->clear();
    samplerManager->clear();
    // Whichever view last filled the shared panel, its trace is gone now.
    rangeDetails->reset();
    rightPane->setCurrentWidget(welcomePage);
    RPC::notifyTraceDiscarded();
}

void WindowPrivate::doLoad(const FilePath &filePath)
{
    settings().lastTraceFile.setValue(filePath);
    combinedLoader->cancel();
    rightPane->setCurrentWidget(traceArea);
    setTraceDuration(milliseconds{0});
    rangeDetails->reset(); // Don't carry the previous trace's details over.
    progressIndicator->show();
    lastLoadError.clear();
    RPC::notifyTraceFileLoadingStarted(filePath);

    // Pick the manager by file format. A directory or a bare "metadata" file
    // holds either a recorded sampler trace or a generic Common Trace Format
    // trace; a .json file is Chrome Trace Format; anything else is treated as
    // a QML profiler trace.
    if (filePath.isDir() || filePath.fileName() == "metadata"_L1) {
        const FilePath dir = filePath.isDir() ? filePath : filePath.parentDir();
        if (CombinedSampler::isCombinedTrace(dir)) {
            // A combined bundle holds both a native sampler trace and a QML trace.
            // Show the combined layout straight away (so there is no flash of the
            // QML-only layout), populate the QML views from the bundle's .qtd, and
            // merge the two into one native-mixed sampler trace for the sampler
            // views (asynchronously; see the merged() handler).
            setActiveFormat(Format::Combined);
            qmlManager->loadTraceFile(dir / combinedQmlFileName);
            combinedLoader->load(dir);
        } else if (SamplerViewManager::isSamplerTrace(dir)) {
            setActiveFormat(Format::Sampler);
            samplerManager->load(dir);
        } else {
            setActiveFormat(Format::Ctf);
            ctfManager->loadCtf2(dir);
        }
    } else if (filePath.suffix() == "json"_L1) {
        setActiveFormat(Format::Ctf);
        ctfManager->loadJson(filePath);
    } else {
        setActiveFormat(Format::Qml);
        qmlManager->loadTraceFile(filePath);
    }
}

void WindowPrivate::setTraceDuration(milliseconds ms)
{
    const int msCount = ms.count();
    const QTime durationTime = QTime::fromMSecsSinceStartOfDay(msCount);
    const QString format = msCount >= 60 * 60 * 1000
                               ? "H:mm:ss' h'"_L1
                               : msCount >= 60 * 1000 ? "m:ss' m'"_L1
                                                      : "s.z' s'"_L1;
    const QString durationString = durationTime.toString(format);
    const QString text = Tr::tr("Trace duration: %1").arg(durationString);
    traceDurationLabel->setText(text);
}

void WindowPrivate::openHelpInBrowser()
{
    QDesktopServices::openUrl({"https://doc.qt.io/qtcreator/creator-qml-performance-monitor.html"});
}

Window::Window(QWidget *parent)
    : QMainWindow(parent)
    , d(new WindowPrivate(this))
{
    auto loadAction = new QAction(Icons::OPENFILE_TOOLBAR.icon(), Tr::tr("Load Trace"), this);
    loadAction->setToolTip(Tr::tr("Load a QML or Chrome Trace Format trace file."));
    loadAction->setShortcut(QKeySequence::Open);
    connect(loadAction, &QAction::triggered, d, &WindowPrivate::showOpenFileDialog);

    // Loading a Common Trace Format *directory* needs a directory picker, which blocks in a
    // nested event loop (QFileDialog::getExistingDirectory) and which the browser cannot offer
    // anyway (it only hands us file content, not a browsable directory). Omit the action on
    // WebAssembly rather than crash on a dead-end feature.
    QAction *loadCtfDirAction = nullptr;
#ifndef Q_OS_WASM
    loadCtfDirAction
        = new QAction(Icons::DIR.icon(), Tr::tr("Load Common Trace Format Directory"), this);
    loadCtfDirAction->setToolTip(Tr::tr("Load a Common Trace Format trace directory."));
    connect(loadCtfDirAction, &QAction::triggered, d, &WindowPrivate::showOpenCtfDirDialog);
#endif

    auto clearAction = new QAction(Icons::CLEAN_TOOLBAR.icon(), Tr::tr("Discard Data"), this);
    clearAction->setShortcut(QKeySequence::Delete);
    connect(clearAction, &QAction::triggered, d, &WindowPrivate::clearTrace);

    auto helpAction = new QAction("?", this);
    helpAction->setToolTip(Tr::tr("Open Help in Web Browser"));
    helpAction->setShortcut(QKeySequence::HelpContents);
    connect(helpAction, &QAction::triggered, this, &WindowPrivate::openHelpInBrowser);

    d->traceDurationLabel = new QLabel;
    d->traceDurationLabel->setContentsMargins(SpacingTokens::PaddingHM, 0,
                                              SpacingTokens::PaddingHM, 0);
    d->traceDurationLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);

    auto toolBar = new QToolBar;
    toolBar->setObjectName("QmlProfileTraceViewer");
    QList<QAction *> toolBarActions{loadAction};
    if (loadCtfDirAction)
        toolBarActions << loadCtfDirAction;
    toolBarActions << clearAction;
    toolBar->addActions(toolBarActions);
    toolBar->addSeparator();
    toolBar->addWidget(d->traceDurationLabel);
    toolBar->addAction(helpAction);
    toolBar->setMovable(false);
    toolBar->setIconSize(QSize(16, 16));
    addToolBar(toolBar);

    // Views are built lazily when the first trace of each format is shown, so
    // there is nothing to lay out yet. The reset-layout action re-lays out
    // whichever format is active once a trace has been loaded.
    connect(d->traceArea, &FancyMainWindow::resetLayout, d, &WindowPrivate::resetActiveLayout);

    auto splitter = new Core::MiniSplitter(Qt::Horizontal);
    splitter->addWidget(d->sidebar);
    splitter->addWidget(d->rightPane);
    splitter->setStretchFactor(0, 0);
    splitter->setStretchFactor(1, 1);
    splitter->setSizes({200, 800});
    setCentralWidget(splitter);

    d->progressIndicator->attachToWidget(this);

    restoreGeometry(settings().windowGeometry());
}

void Window::loadTraceFile(const FilePath &filePath)
{
    d->sidebar->addTrace(filePath); // Registers + selects it without triggering a reload.
    d->doLoad(filePath);
}

bool Window::selectBackend(const QString &name)
{
    return d->selectBackendByName(name);
}

void Window::startTimedRecording(std::chrono::milliseconds duration)
{
    d->startTimedRecording(duration);
}

void Window::closeEvent(QCloseEvent *event)
{
    settings().windowGeometry.setValue(saveGeometry());
    for (const std::unique_ptr<Sampler> &backend : d->backends) {
        if (Utils::AspectContainer *s = backend->settings())
            s->writeSettings();
    }
    d->stopAndWaitForRecording();
    QMainWindow::closeEvent(event);
}

} // namespace QtProfiler
