/*
    This file is part of the KDE project
    SPDX-FileCopyrightText: 2021 Kai Uwe Broulik <kde@broulik.de>

    SPDX-License-Identifier: LGPL-2.0-or-later
*/

#include "kuiserverv2jobtracker.h"
#include "kuiserverv2jobtracker_p.h"

#include "debug.h"
#include "jobviewv3iface.h"

#include <KJob>

#include <QDBusConnection>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QGuiApplication>
#include <QHash>
#include <QTimer>
#include <QVarLengthArray>
#include <QVariantMap>
#include <QtGlobal>

Q_GLOBAL_STATIC(KSharedUiServerV2Proxy, serverProxy)

namespace
{
constexpr quintptr INVALID = -1;
struct TrackedJob {
    std::unique_ptr<QTimer> delayTimer;
    std::unique_ptr<org::kde::JobViewV3> jobView;
    quintptr jobId = INVALID;
    QPointer<KJob> job;
    QVariantMap currentState;
    QVariantMap pendingUpdates;
};
}

class KUiServerV2JobTrackerPrivate
{
public:
    KUiServerV2JobTrackerPrivate(KUiServerV2JobTracker *parent)
        : q(parent)
    {
        updateTimer.setInterval(0);
        updateTimer.setSingleShot(true);
        QObject::connect(&updateTimer, &QTimer::timeout, q, [this] {
            sendAllUpdates();
        });
    }

    KUiServerV2JobTracker *const q;

    void sendAllUpdates();
    void sendUpdate(TrackedJob *view);
    void scheduleUpdate(KJob *job, const QString &key, const QVariant &value);

    void updateDestUrl(KJob *job);

    void requestView(KJob *job, const QString &desktopEntry);

    TrackedJob *findJob(KJob *job)
    {
        auto id = reinterpret_cast<quintptr>(job);
        auto it = std::find_if(jobs.begin(), jobs.end(), [id](const TrackedJob &j) {
            return j.jobId == id;
        });
        return it == jobs.end() ? nullptr : &*it;
    }

    TrackedJob *addJob(KJob *job)
    {
        Q_ASSERT(findJob(job) == nullptr);
        TrackedJob j;
        j.job = job;
        j.jobId = reinterpret_cast<quintptr>(job);
        jobs.push_back(std::move(j));
        return &jobs.back();
    }

    void removeJob(KJob *job)
    {
        auto id = reinterpret_cast<quintptr>(job);
        jobs.erase(std::remove_if(jobs.begin(),
                                  jobs.end(),
                                  [id](const TrackedJob &j) {
                                      return j.jobId == id;
                                  }),
                   jobs.end());
    }

    std::vector<TrackedJob> jobs;
    QTimer updateTimer;

    QMetaObject::Connection serverRegisteredConnection;
};

void KUiServerV2JobTrackerPrivate::scheduleUpdate(KJob *job, const QString &key, const QVariant &value)
{
    TrackedJob *trackedJob = findJob(job);
    if (!trackedJob) {
        return;
    }

    trackedJob->currentState[key] = value;
    trackedJob->pendingUpdates[key] = value;

    if (!updateTimer.isActive()) {
        updateTimer.start();
    }
}

void KUiServerV2JobTrackerPrivate::sendAllUpdates()
{
    for (TrackedJob &j : jobs) {
        sendUpdate(&j);
    }
}

void KUiServerV2JobTrackerPrivate::sendUpdate(TrackedJob *tracker)
{
    if (!tracker->jobView) {
        return;
    }

    const QVariantMap updates = tracker->pendingUpdates;
    if (updates.isEmpty()) {
        return;
    }

    tracker->jobView->update(updates);
    tracker->pendingUpdates.clear();
}

void KUiServerV2JobTrackerPrivate::updateDestUrl(KJob *job)
{
    scheduleUpdate(job, QStringLiteral("destUrl"), job->property("destUrl").toString());
}

void KUiServerV2JobTrackerPrivate::requestView(KJob *job, const QString &desktopEntry)
{
    TrackedJob *tracked = findJob(job);
    if (!tracked) {
        return;
    }

    QVariantMap hints = tracked->currentState;
    // Tells Plasma to show the job view right away, since the delay is always handled on our side
    hints.insert(QStringLiteral("immediate"), true);
    // Must not clear currentState as only Plasma 5.22+ will use properties from "hints",
    // there must still be a full update() call for earlier versions!

    if (job->isFinishedNotificationHidden()) {
        hints.insert(QStringLiteral("transient"), true);
    }

    const QDBusPendingReply<QDBusObjectPath> reply = serverProxy()->uiserver()->requestView(desktopEntry, job->capabilities(), hints);

    QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply, q);
    QObject::connect(watcher, &QDBusPendingCallWatcher::finished, q, [this, watcher, job] {
        QDBusPendingReply<QDBusObjectPath> reply = *watcher;
        watcher->deleteLater();

        if (reply.isError()) {
            qCWarning(KJOBWIDGETS) << "Failed to register job with KUiServerV2JobTracker" << reply.error().message();
            removeJob(job);
            return;
        }

        const QString viewObjectPath = reply.value().path();
        auto uniqueJobView = std::make_unique<org::kde::JobViewV3>(QStringLiteral("org.kde.JobViewServer"), viewObjectPath, QDBusConnection::sessionBus());
        auto *jobView = uniqueJobView.get();

        TrackedJob *trackedJob = findJob(job);
        if (trackedJob->job) {
            QObject::connect(jobView, &org::kde::JobViewV3::cancelRequested, job, [job] {
                job->kill(KJob::EmitResult);
            });
            QObject::connect(jobView, &org::kde::JobViewV3::suspendRequested, job, &KJob::suspend);
            QObject::connect(jobView, &org::kde::JobViewV3::resumeRequested, job, &KJob::resume);

            trackedJob->jobView = std::move(uniqueJobView);
        }

        // Now send the full current job state over
        jobView->update(trackedJob->currentState);
        // which also contains all pending updates
        trackedJob->pendingUpdates.clear();

        // Job was deleted or finished in the meantime
        if (!trackedJob->job || trackedJob->currentState.value(QStringLiteral("terminated")).toBool()) {
            const uint errorCode = trackedJob->currentState.value(QStringLiteral("errorCode")).toUInt();
            const QString errorMessage = trackedJob->currentState.value(QStringLiteral("errorMessage")).toString();

            jobView->terminate(errorCode, errorMessage, QVariantMap() /*hints*/);
            removeJob(job);
        }
    });
}

KUiServerV2JobTracker::KUiServerV2JobTracker(QObject *parent)
    : KJobTrackerInterface(parent)
    , d(new KUiServerV2JobTrackerPrivate(this))
{
    qDBusRegisterMetaType<qulonglong>();
}

KUiServerV2JobTracker::~KUiServerV2JobTracker()
{
    if (!d->jobs.empty()) {
        qCWarning(KJOBWIDGETS) << "A KUiServerV2JobTracker instance contains" << d->jobs.size() << "stalled jobs";
    }
}

void KUiServerV2JobTracker::registerJob(KJob *job)
{
    // already exists
    if (d->findJob(job)) {
        return;
    }

    TrackedJob *const jobTracker = d->addJob(job);

    QString desktopEntry = job->property("desktopFileName").toString();
    if (desktopEntry.isEmpty()) {
        desktopEntry = QGuiApplication::desktopFileName();
    }

    if (desktopEntry.isEmpty()) {
        qCWarning(KJOBWIDGETS) << "Cannot register a job with KUiServerV2JobTracker without QGuiApplication::desktopFileName";
        return;
    }

    // Watch the server registering/unregistering and re-register the jobs as needed
    if (!d->serverRegisteredConnection) {
        d->serverRegisteredConnection = connect(serverProxy(), &KSharedUiServerV2Proxy::serverRegistered, this, [this]() {
            /**
             * The code below just takes all existing jobs where jobView and KJob are valid
             * and then just registers them again
             */
            QVarLengthArray<std::pair<KJob *, QVariantMap>, 32> jobsToReRegister;
            // Delete the old views, remove the old struct but keep the state
            for (const TrackedJob &j : d->jobs) {
                if (j.jobView) {
                    const QVariantMap &oldState = j.currentState;

                    // It is possible that the KJob has been deleted already so do not
                    // use or dereference if marked as terminated
                    if (oldState.value(QStringLiteral("terminated")).toBool()) {
                        const uint errorCode = oldState.value(QStringLiteral("errorCode")).toUInt();
                        const QString errorMessage = oldState.value(QStringLiteral("errorMessage")).toString();

                        j.jobView->terminate(errorCode, errorMessage, /*hints=*/QVariantMap());
                        continue;
                    }

                    if (j.job) {
                        // try register again
                        jobsToReRegister.push_back({j.job, oldState});
                    }
                }
            }
            d->jobs.clear();

            // and then restore its previous state, which is safe because the DBus
            // is async and is only processed once event loop returns
            for (const auto &[job, oldState] : jobsToReRegister) {
                registerJob(job);
                auto tracked = d->findJob(job);
                Q_ASSERT(tracked);
                tracked->currentState = oldState;
            }
        });
    }

    // Send along current job state
    if (job->isSuspended()) {
        suspended(job);
    }
    if (job->error()) {
        d->scheduleUpdate(job, QStringLiteral("errorCode"), static_cast<uint>(job->error()));
        d->scheduleUpdate(job, QStringLiteral("errorMessage"), job->errorText());
    }
    for (int i = KJob::Bytes; i <= KJob::Items; ++i) {
        const auto unit = static_cast<KJob::Unit>(i);

        if (job->processedAmount(unit) > 0) {
            processedAmount(job, unit, job->processedAmount(unit));
        }
        if (job->totalAmount(unit) > 0) {
            totalAmount(job, unit, job->totalAmount(unit));
        }
    }
    if (job->percent() > 0) {
        percent(job, job->percent());
    }
    d->updateDestUrl(job);

    if (job->property("immediateProgressReporting").toBool()) {
        d->requestView(job, desktopEntry);
    } else {
        QPointer<KJob> jobGuard = job;

        QTimer *delayTimer = new QTimer();
        delayTimer->setSingleShot(true);
        connect(delayTimer, &QTimer::timeout, this, [this, job, jobGuard, desktopEntry] {
            if (TrackedJob *j = d->findJob(job); j && j->job) {
                if (j->delayTimer) {
                    j->delayTimer->deleteLater();
                    j->delayTimer = nullptr;
                }
                d->requestView(job, desktopEntry);
            }
        });

        jobTracker->delayTimer.reset(delayTimer);
        delayTimer->start(500);
    }

    KJobTrackerInterface::registerJob(job);
}

void KUiServerV2JobTracker::unregisterJob(KJob *job)
{
    KJobTrackerInterface::unregisterJob(job);
    finished(job);
}

void KUiServerV2JobTracker::finished(KJob *job)
{
    d->updateDestUrl(job);

    TrackedJob *trackedJob = d->findJob(job);
    if (!trackedJob) {
        return;
    }

    // send all pending updates before terminating to ensure state is correct
    d->sendUpdate(trackedJob);

    if (trackedJob->delayTimer) {
        d->removeJob(job);
    } else if (trackedJob->jobView) {
        trackedJob->jobView->terminate(static_cast<uint>(job->error()), job->error() ? job->errorText() : QString(), QVariantMap() /*hints*/);
        d->removeJob(job);
    } else {
        // Remember that the job finished in the meantime and
        // terminate the JobView once it arrives
        d->scheduleUpdate(job, QStringLiteral("terminated"), true);
        if (job->error()) {
            d->scheduleUpdate(job, QStringLiteral("errorCode"), static_cast<uint>(job->error()));
            d->scheduleUpdate(job, QStringLiteral("errorMessage"), job->errorText());
        }
    }
}

void KUiServerV2JobTracker::suspended(KJob *job)
{
    d->scheduleUpdate(job, QStringLiteral("suspended"), true);
}

void KUiServerV2JobTracker::resumed(KJob *job)
{
    d->scheduleUpdate(job, QStringLiteral("suspended"), false);
}

void KUiServerV2JobTracker::description(KJob *job, const QString &title, const QPair<QString, QString> &field1, const QPair<QString, QString> &field2)
{
    d->scheduleUpdate(job, QStringLiteral("title"), title);

    d->scheduleUpdate(job, QStringLiteral("descriptionLabel1"), field1.first);
    d->scheduleUpdate(job, QStringLiteral("descriptionValue1"), field1.second);

    d->scheduleUpdate(job, QStringLiteral("descriptionLabel2"), field2.first);
    d->scheduleUpdate(job, QStringLiteral("descriptionValue2"), field2.second);
}

void KUiServerV2JobTracker::infoMessage(KJob *job, const QString &message)
{
    d->scheduleUpdate(job, QStringLiteral("infoMessage"), message);
}

void KUiServerV2JobTracker::totalAmount(KJob *job, KJob::Unit unit, qulonglong amount)
{
    switch (unit) {
    case KJob::Bytes:
        d->scheduleUpdate(job, QStringLiteral("totalBytes"), amount);
        break;
    case KJob::Files:
        d->scheduleUpdate(job, QStringLiteral("totalFiles"), amount);
        break;
    case KJob::Directories:
        d->scheduleUpdate(job, QStringLiteral("totalDirectories"), amount);
        break;
    case KJob::Items:
        d->scheduleUpdate(job, QStringLiteral("totalItems"), amount);
        break;
    case KJob::UnitsCount:
        Q_UNREACHABLE();
        break;
    }
}

void KUiServerV2JobTracker::processedAmount(KJob *job, KJob::Unit unit, qulonglong amount)
{
    switch (unit) {
    case KJob::Bytes:
        d->scheduleUpdate(job, QStringLiteral("elapsedTime"), job->elapsedTime());
        d->scheduleUpdate(job, QStringLiteral("processedBytes"), amount);
        break;
    case KJob::Files:
        d->scheduleUpdate(job, QStringLiteral("processedFiles"), amount);
        break;
    case KJob::Directories:
        d->scheduleUpdate(job, QStringLiteral("processedDirectories"), amount);
        break;
    case KJob::Items:
        d->scheduleUpdate(job, QStringLiteral("processedItems"), amount);
        break;
    case KJob::UnitsCount:
        Q_UNREACHABLE();
        break;
    }
}

void KUiServerV2JobTracker::percent(KJob *job, unsigned long percent)
{
    d->scheduleUpdate(job, QStringLiteral("percent"), static_cast<uint>(percent));
}

void KUiServerV2JobTracker::speed(KJob *job, unsigned long speed)
{
    d->scheduleUpdate(job, QStringLiteral("speed"), static_cast<qulonglong>(speed));
}

KSharedUiServerV2Proxy::KSharedUiServerV2Proxy()
    : m_uiserver(new org::kde::JobViewServerV2(QStringLiteral("org.kde.JobViewServer"), QStringLiteral("/JobViewServer"), QDBusConnection::sessionBus()))
    , m_watcher(new QDBusServiceWatcher(QStringLiteral("org.kde.JobViewServer"), QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange))
{
    connect(m_watcher.get(), &QDBusServiceWatcher::serviceOwnerChanged, this, &KSharedUiServerV2Proxy::uiserverOwnerChanged);

    // cleanup early enough to avoid issues with dbus at application exit
    // see e.g. https://phabricator.kde.org/D2545
    qAddPostRoutine([]() {
        serverProxy->m_uiserver.reset();
        serverProxy->m_watcher.reset();
    });
}

KSharedUiServerV2Proxy::~KSharedUiServerV2Proxy()
{
}

org::kde::JobViewServerV2 *KSharedUiServerV2Proxy::uiserver()
{
    return m_uiserver.get();
}

void KSharedUiServerV2Proxy::uiserverOwnerChanged(const QString &serviceName, const QString &oldOwner, const QString &newOwner)
{
    Q_UNUSED(serviceName);
    Q_UNUSED(oldOwner);

    if (!newOwner.isEmpty()) { // registered
        Q_EMIT serverRegistered();
    } else if (newOwner.isEmpty()) { // unregistered
        Q_EMIT serverUnregistered();
    }
}

#include "moc_kuiserverv2jobtracker.cpp"
#include "moc_kuiserverv2jobtracker_p.cpp"
