/*
    SPDX-FileCopyrightText: 2008 Dmitry Suzdalev <dimsuz@gmail.com>
    SPDX-FileCopyrightText: 2017 David Edmundson <davidedmundson@kde.org>
    SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>

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

#include "notification.h"
#include "notification_p.h"

#include <QDBusArgument>
#include <QDebug>
#include <QImageReader>
#include <QRegularExpression>
#include <QXmlStreamReader>

#include <KApplicationTrader>
#include <KConfig>
#include <KConfigGroup>
#include <KService>

#include "debug.h"

using namespace NotificationManager;

Notification::Private::Private()
{
}

Notification::Private::~Private() = default;

QString Notification::Private::sanitize(const QString &text)
{
    // replace all \ns with <br/>
    QString t = text;

    t.replace(QLatin1String("\n"), QStringLiteral("<br/>"));
    // Now remove all inner whitespace (\ns are already <br/>s)
    t = t.simplified();
    // Finally, check if we don't have multiple <br/>s following,
    // can happen for example when "\n       \n" is sent, this replaces
    // all <br/>s in succession with just one
    t.replace(QRegularExpression(QStringLiteral("<br/>\\s*<br/>(\\s|<br/>)*")), QLatin1String("<br/>"));
    // This fancy RegExp escapes every occurrence of & since QtQuick Text will blatantly cut off
    // text where it finds a stray ampersand.
    // Only &{apos, quot, gt, lt, amp}; as well as &#123 character references will be allowed
    t.replace(QRegularExpression(QStringLiteral("&(?!(?:apos|quot|[gl]t|amp);|#)")), QLatin1String("&amp;"));

    // Don't bother adding some HTML structure if the body is now empty
    if (t.isEmpty()) {
        return t;
    }

    QXmlStreamReader r(QStringLiteral("<html>") + t + QStringLiteral("</html>"));
    QString result;
    QXmlStreamWriter out(&result);

    const QVector<QString> allowedTags = {"b", "i", "u", "img", "a", "html", "br", "table", "tr", "td"};

    out.writeStartDocument();
    while (!r.atEnd()) {
        r.readNext();

        if (r.tokenType() == QXmlStreamReader::StartElement) {
            const QString name = r.name().toString();
            if (!allowedTags.contains(name)) {
                continue;
            }
            out.writeStartElement(name);
            if (name == QLatin1String("img")) {
                auto src = r.attributes().value("src").toString();
                auto alt = r.attributes().value("alt").toString();

                const QUrl url(src);
                if (url.isLocalFile()) {
                    out.writeAttribute(QStringLiteral("src"), src);
                } else {
                    // image denied for security reasons! Do not copy the image src here!
                }

                out.writeAttribute(QStringLiteral("alt"), alt);
            }
            if (name == QLatin1Char('a')) {
                out.writeAttribute(QStringLiteral("href"), r.attributes().value("href").toString());
            }
        }

        if (r.tokenType() == QXmlStreamReader::EndElement) {
            const QString name = r.name().toString();
            if (!allowedTags.contains(name)) {
                continue;
            }
            out.writeEndElement();
        }

        if (r.tokenType() == QXmlStreamReader::Characters) {
            const auto text = r.text().toString();
            out.writeCharacters(text); // this auto escapes chars -> HTML entities
        }
    }
    out.writeEndDocument();

    if (r.hasError()) {
        qCWarning(NOTIFICATIONMANAGER) << "Notification to send to backend contains invalid XML: " << r.errorString() << "line" << r.lineNumber() << "col"
                                       << r.columnNumber();
    }

    // The Text.StyledText format handles only html3.2 stuff and &apos; is html4 stuff
    // so we need to replace it here otherwise it will not render at all.
    result.replace(QLatin1String("&apos;"), QChar('\''));

    return result;
}

QImage Notification::Private::decodeNotificationSpecImageHint(const QDBusArgument &arg)
{
    int width, height, rowStride, hasAlpha, bitsPerSample, channels;
    QByteArray pixels;
    char *ptr;
    char *end;

    arg.beginStructure();
    arg >> width >> height >> rowStride >> hasAlpha >> bitsPerSample >> channels >> pixels;
    arg.endStructure();

#define SANITY_CHECK(condition)                                                                                                                                \
    if (!(condition)) {                                                                                                                                        \
        qCWarning(NOTIFICATIONMANAGER) << "Image decoding sanity check failed on" << #condition;                                                               \
        return QImage();                                                                                                                                       \
    }

    SANITY_CHECK(width > 0);
    SANITY_CHECK(width < 2048);
    SANITY_CHECK(height > 0);
    SANITY_CHECK(height < 2048);
    SANITY_CHECK(rowStride > 0);

#undef SANITY_CHECK

    auto copyLineRGB32 = [](QRgb *dst, const char *src, int width) {
        const char *end = src + width * 3;
        for (; src != end; ++dst, src += 3) {
            *dst = qRgb(src[0], src[1], src[2]);
        }
    };

    auto copyLineARGB32 = [](QRgb *dst, const char *src, int width) {
        const char *end = src + width * 4;
        for (; src != end; ++dst, src += 4) {
            *dst = qRgba(src[0], src[1], src[2], src[3]);
        }
    };

    QImage::Format format = QImage::Format_Invalid;
    void (*fcn)(QRgb *, const char *, int) = nullptr;
    if (bitsPerSample == 8) {
        if (channels == 4) {
            format = QImage::Format_ARGB32;
            fcn = copyLineARGB32;
        } else if (channels == 3) {
            format = QImage::Format_RGB32;
            fcn = copyLineRGB32;
        }
    }
    if (format == QImage::Format_Invalid) {
        qCWarning(NOTIFICATIONMANAGER) << "Unsupported image format (hasAlpha:" << hasAlpha << "bitsPerSample:" << bitsPerSample << "channels:" << channels
                                       << ")";
        return QImage();
    }

    QImage image(width, height, format);
    ptr = pixels.data();
    end = ptr + pixels.length();
    for (int y = 0; y < height; ++y, ptr += rowStride) {
        if (ptr + channels * width > end) {
            qCWarning(NOTIFICATIONMANAGER) << "Image data is incomplete. y:" << y << "height:" << height;
            break;
        }
        fcn((QRgb *)image.scanLine(y), ptr, width);
    }

    return image;
}

void Notification::Private::sanitizeImage(QImage &image)
{
    if (image.isNull()) {
        return;
    }

    const QSize max = maximumImageSize();
    if (image.size().width() > max.width() || image.size().height() > max.height()) {
        image = image.scaled(max, Qt::KeepAspectRatio, Qt::SmoothTransformation);
    }
}

void Notification::Private::loadImagePath(const QString &path)
{
    // image_path and appIcon should either be a URL with file scheme or the name of a themed icon.
    // We're lenient and also allow local paths.

    image = QImage(); // clear
    icon.clear();

    QUrl imageUrl;
    if (path.startsWith(QLatin1Char('/'))) {
        imageUrl = QUrl::fromLocalFile(path);
    } else if (path.contains(QLatin1Char('/'))) { // bad heuristic to detect a URL
        imageUrl = QUrl(path);

        if (!imageUrl.isLocalFile()) {
            qCDebug(NOTIFICATIONMANAGER) << "Refused to load image from" << path << "which isn't a valid local location.";
            return;
        }
    }

    if (!imageUrl.isValid()) {
        // try icon path instead;
        icon = path;
        return;
    }

    QImageReader reader(imageUrl.toLocalFile());
    reader.setAutoTransform(true);

    const QSize imageSize = reader.size();
    if (imageSize.isValid() && (imageSize.width() > maximumImageSize().width() || imageSize.height() > maximumImageSize().height())) {
        const QSize thumbnailSize = imageSize.scaled(maximumImageSize(), Qt::KeepAspectRatio);
        reader.setScaledSize(thumbnailSize);
    }

    image = reader.read();
}

QString Notification::Private::defaultComponentName()
{
    // NOTE Keep in sync with KNotification
    return QStringLiteral("plasma_workspace");
}

QSize Notification::Private::maximumImageSize()
{
    return QSize(256, 256);
}

KService::Ptr Notification::Private::serviceForDesktopEntry(const QString &desktopEntry)
{
    KService::Ptr service;

    if (desktopEntry.startsWith(QLatin1Char('/'))) {
        service = KService::serviceByDesktopPath(desktopEntry);
    } else {
        service = KService::serviceByDesktopName(desktopEntry);
    }

    if (!service) {
        const QString lowerDesktopEntry = desktopEntry.toLower();
        service = KService::serviceByDesktopName(lowerDesktopEntry);
    }

    // Try if it's a renamed flatpak
    if (!service) {
        const QString desktopId = desktopEntry + QLatin1String(".desktop");

        const auto services = KApplicationTrader::query([&desktopId](const KService::Ptr &app) -> bool {
            const QString renamedFrom = app->property(QStringLiteral("X-Flatpak-RenamedFrom"), QVariant::String).toString();

            if (renamedFrom.isEmpty()) {
                return false;
            }

            const auto names = renamedFrom.split(QChar(';'));
            for (const QString &name : names) {
                if (name == desktopId) {
                    return true;
                }
            }
            return false;
        });

        if (!services.isEmpty()) {
            service = services.first();
        }
    }

    return service;
}

void Notification::Private::setDesktopEntry(const QString &desktopEntry)
{
    QString serviceName;

    configurableService = false;

    KService::Ptr service = serviceForDesktopEntry(desktopEntry);
    if (service) {
        this->desktopEntry = service->desktopEntryName();
        serviceName = service->name();
        applicationIconName = service->icon();
        configurableService = !service->noDisplay();
    }

    const bool isDefaultEvent = (notifyRcName == defaultComponentName());
    configurableNotifyRc = false;
    if (!notifyRcName.isEmpty()) {
        // Check whether the application actually has notifications we can configure
        KConfig config(notifyRcName + QStringLiteral(".notifyrc"), KConfig::NoGlobals);
        config.addConfigSources(
            QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications5/") + notifyRcName + QStringLiteral(".notifyrc")));

        KConfigGroup globalGroup(&config, "Global");

        const QString iconName = globalGroup.readEntry("IconName");

        // also only overwrite application icon name for non-default events (or if we don't have a service icon)
        if (!iconName.isEmpty() && (!isDefaultEvent || applicationIconName.isEmpty())) {
            applicationIconName = iconName;
        }

        const QRegularExpression regexp(QStringLiteral("^Event/([^/]*)$"));
        configurableNotifyRc = !config.groupList().filter(regexp).isEmpty();
    }

    // For default events we try to show the application name from the desktop entry if possible
    // This will have us show e.g. "Dr Konqi" instead of generic "Plasma Desktop"
    // The application may not send an applicationName. Use the name from the desktop entry then
    if ((isDefaultEvent || applicationName.isEmpty()) && !serviceName.isEmpty()) {
        applicationName = serviceName;
    }
}

void Notification::Private::processHints(const QVariantMap &hints)
{
    auto end = hints.end();

    notifyRcName = hints.value(QStringLiteral("x-kde-appname")).toString();

    setDesktopEntry(hints.value(QStringLiteral("desktop-entry")).toString());

    // Special override for KDE Connect since the notification is sent by kdeconnectd
    // but actually comes from a different app on the phone
    const QString applicationDisplayName = hints.value(QStringLiteral("x-kde-display-appname")).toString();
    if (!applicationDisplayName.isEmpty()) {
        applicationName = applicationDisplayName;
    }

    originName = hints.value(QStringLiteral("x-kde-origin-name")).toString();

    eventId = hints.value(QStringLiteral("x-kde-eventId")).toString();
    xdgTokenAppId = hints.value(QStringLiteral("x-kde-xdgTokenAppId")).toString();

    bool ok;
    const int urgency = hints.value(QStringLiteral("urgency")).toInt(&ok); // DBus type is actually "byte"
    if (ok) {
        // FIXME use separate enum again
        switch (urgency) {
        case 0:
            setUrgency(Notifications::LowUrgency);
            break;
        case 1:
            setUrgency(Notifications::NormalUrgency);
            break;
        case 2:
            setUrgency(Notifications::CriticalUrgency);
            break;
        }
    }

    resident = hints.value(QStringLiteral("resident")).toBool();
    transient = hints.value(QStringLiteral("transient")).toBool();

    userActionFeedback = hints.value(QStringLiteral("x-kde-user-action-feedback")).toBool();
    if (userActionFeedback) {
        // A confirmation of an explicit user interaction is assumed to have been seen by the user.
        read = true;
    }

    urls = QUrl::fromStringList(hints.value(QStringLiteral("x-kde-urls")).toStringList());

    replyPlaceholderText = hints.value(QStringLiteral("x-kde-reply-placeholder-text")).toString();
    replySubmitButtonText = hints.value(QStringLiteral("x-kde-reply-submit-button-text")).toString();
    replySubmitButtonIconName = hints.value(QStringLiteral("x-kde-reply-submit-button-icon-name")).toString();

    category = hints.value(QStringLiteral("category")).toString();

    // Underscored hints was in use in version 1.1 of the spec but has been
    // replaced by dashed hints in version 1.2. We need to support it for
    // users of the 1.2 version of the spec.
    auto it = hints.find(QStringLiteral("image-data"));
    if (it == end) {
        it = hints.find(QStringLiteral("image_data"));
    }
    if (it == end) {
        // This hint was in use in version 1.0 of the spec but has been
        // replaced by "image_data" in version 1.1. We need to support it for
        // users of the 1.0 version of the spec.
        it = hints.find(QStringLiteral("icon_data"));
    }

    if (it != end) {
        image = decodeNotificationSpecImageHint(it->value<QDBusArgument>());
    }

    if (image.isNull()) {
        it = hints.find(QStringLiteral("image-path"));
        if (it == end) {
            it = hints.find(QStringLiteral("image_path"));
        }

        if (it != end) {
            loadImagePath(it->toString());
        }
    }

    sanitizeImage(image);
}

void Notification::Private::setUrgency(Notifications::Urgency urgency)
{
    this->urgency = urgency;

    // Critical notifications must not time out
    // TODO should we really imply this here and not on the view side?
    // are there usecases for critical but can expire?
    // "critical updates available"?
    if (urgency == Notifications::CriticalUrgency) {
        timeout = 0;
    }
}

Notification::Notification(uint id)
    : d(new Private())
{
    d->id = id;
    d->created = QDateTime::currentDateTimeUtc();
}

Notification::Notification(const Notification &other)
    : d(new Private(*other.d))
{
}

Notification::Notification(Notification &&other) noexcept
    : d(other.d)
{
    other.d = nullptr;
}

Notification &Notification::operator=(const Notification &other)
{
    *d = *other.d;
    return *this;
}

Notification &Notification::operator=(Notification &&other) noexcept
{
    d = other.d;
    other.d = nullptr;
    return *this;
}

Notification::~Notification()
{
    delete d;
}

uint Notification::id() const
{
    return d->id;
}

QString Notification::dBusService() const
{
    return d->dBusService;
}

void Notification::setDBusService(const QString &dBusService)
{
    d->dBusService = dBusService;
}

QDateTime Notification::created() const
{
    return d->created;
}

void Notification::setCreated(const QDateTime &created)
{
    d->created = created;
}

QDateTime Notification::updated() const
{
    return d->updated;
}

void Notification::resetUpdated()
{
    d->updated = QDateTime::currentDateTimeUtc();
}

bool Notification::read() const
{
    return d->read;
}

void Notification::setRead(bool read)
{
    d->read = read;
}

QString Notification::summary() const
{
    return d->summary;
}

void Notification::setSummary(const QString &summary)
{
    d->summary = summary;
}

QString Notification::body() const
{
    return d->body;
}

void Notification::setBody(const QString &body)
{
    d->rawBody = body;
    d->body = Private::sanitize(body.trimmed());
}

QString Notification::rawBody() const
{
    return d->rawBody;
}

QString Notification::icon() const
{
    return d->icon;
}

void Notification::setIcon(const QString &icon)
{
    d->loadImagePath(icon);
    Private::sanitizeImage(d->image);
}

QImage Notification::image() const
{
    return d->image;
}

void Notification::setImage(const QImage &image)
{
    d->image = image;
}

QString Notification::desktopEntry() const
{
    return d->desktopEntry;
}

void Notification::setDesktopEntry(const QString &desktopEntry)
{
    d->setDesktopEntry(desktopEntry);
}

QString Notification::notifyRcName() const
{
    return d->notifyRcName;
}

QString Notification::eventId() const
{
    return d->eventId;
}

QString Notification::applicationName() const
{
    return d->applicationName;
}

void Notification::setApplicationName(const QString &applicationName)
{
    d->applicationName = applicationName;
}

QString Notification::applicationIconName() const
{
    return d->applicationIconName;
}

void Notification::setApplicationIconName(const QString &applicationIconName)
{
    d->applicationIconName = applicationIconName;
}

QString Notification::originName() const
{
    return d->originName;
}

QStringList Notification::actionNames() const
{
    return d->actionNames;
}

QStringList Notification::actionLabels() const
{
    return d->actionLabels;
}

bool Notification::hasDefaultAction() const
{
    return d->hasDefaultAction;
}

QString Notification::defaultActionLabel() const
{
    return d->defaultActionLabel;
}

void Notification::setActions(const QStringList &actions)
{
    if (actions.count() % 2 != 0) {
        qCWarning(NOTIFICATIONMANAGER) << "List of actions must contain an even number of items, tried to set actions to" << actions;
        return;
    }

    d->hasDefaultAction = false;
    d->hasConfigureAction = false;
    d->hasReplyAction = false;

    QStringList names;
    QStringList labels;

    for (int i = 0; i < actions.count(); i += 2) {
        const QString &name = actions.at(i);
        const QString &label = actions.at(i + 1);

        if (!d->hasDefaultAction && name == QLatin1String("default")) {
            d->hasDefaultAction = true;
            d->defaultActionLabel = label;
            continue;
        }

        if (!d->hasConfigureAction && name == QLatin1String("settings")) {
            d->hasConfigureAction = true;
            d->configureActionLabel = label;
            continue;
        }

        if (!d->hasReplyAction && name == QLatin1String("inline-reply")) {
            d->hasReplyAction = true;
            d->replyActionLabel = label;
            continue;
        }

        names << name;
        labels << label;
    }

    d->actionNames = names;
    d->actionLabels = labels;
}

QList<QUrl> Notification::urls() const
{
    return d->urls;
}

void Notification::setUrls(const QList<QUrl> &urls)
{
    d->urls = urls;
}

Notifications::Urgency Notification::urgency() const
{
    return d->urgency;
}

bool Notification::userActionFeedback() const
{
    return d->userActionFeedback;
}

int Notification::timeout() const
{
    return d->timeout;
}

void Notification::setTimeout(int timeout)
{
    d->timeout = timeout;
}

bool Notification::configurable() const
{
    return d->hasConfigureAction || d->configurableNotifyRc || d->configurableService;
}

QString Notification::configureActionLabel() const
{
    return d->configureActionLabel;
}

bool Notification::hasReplyAction() const
{
    return d->hasReplyAction;
}

QString Notification::replyActionLabel() const
{
    return d->replyActionLabel;
}

QString Notification::replyPlaceholderText() const
{
    return d->replyPlaceholderText;
}

QString Notification::replySubmitButtonText() const
{
    return d->replySubmitButtonText;
}

QString Notification::replySubmitButtonIconName() const
{
    return d->replySubmitButtonIconName;
}

QString Notification::category() const
{
    return d->category;
}

bool Notification::expired() const
{
    return d->expired;
}

void Notification::setExpired(bool expired)
{
    d->expired = expired;
}

bool Notification::dismissed() const
{
    return d->dismissed;
}

void Notification::setDismissed(bool dismissed)
{
    d->dismissed = dismissed;
}

bool Notification::resident() const
{
    return d->resident;
}

void Notification::setResident(bool resident)
{
    d->resident = resident;
}

bool Notification::transient() const
{
    return d->transient;
}

void Notification::setTransient(bool transient)
{
    d->transient = transient;
}

QVariantMap Notification::hints() const
{
    return d->hints;
}

void Notification::setHints(const QVariantMap &hints)
{
    d->hints = hints;
}

void Notification::processHints(const QVariantMap &hints)
{
    d->processHints(hints);
}
