// SPDX-FileCopyrightText: 2023 Linus Jahn <lnj@kaidan.im>
//
// SPDX-License-Identifier: LGPL-2.1-or-later

#include "QXmppBlockingManager.h"

#include "QXmppConstants_p.h"
#include "QXmppIqHandling.h"
#include "QXmppSpamReport.h"
#include "QXmppUtils.h"
#include "QXmppUtils_p.h"

#include "Iq.h"
#include "StringLiterals.h"
#include "XmlWriter.h"

#include <QDomElement>

using namespace QXmpp;
using namespace QXmpp::Private;
using StanzaError = QXmppStanza::Error;

// utility function
template<typename T>
static void makeUnique(T &vec)
{
    std::sort(vec.begin(), vec.end());
    vec.erase(std::unique(vec.begin(), vec.end()), vec.end());
}

// IQs
enum BlockingAction {
    List,
    Block,
    Unblock,
};

template<>
struct Enums::Data<BlockingAction> {
    static constexpr auto Values = makeValues<BlockingAction>({
        { List, u"blocklist" },
        { Block, u"block" },
        { Unblock, u"unblock" },
    });
};

template<BlockingAction Action>
struct Blocking {
    QList<QString> jids;

    static constexpr std::tuple XmlTag = { Enums::toStringView(Action), ns_blocking };

    bool parse(const QDomElement &el)
    {
        if (elementXmlTag(el) == XmlTag) {
            jids = parseSingleAttributeElements<QList<QString>>(el, u"item", ns_blocking, u"jid"_s);
            return true;
        }
        return false;
    }
    void toXml(XmlWriter &w) const
    {
        w.write(Element { XmlTag, SingleAttributeElements { u"item", u"jid", jids } });
    }
};

// XEP-0377: block request carrying a spam report inside the <item/>
struct BlockReport {
    QString jid;
    QXmppSpamReport report;

    static constexpr std::tuple XmlTag = { u"block", ns_blocking };

    // Only ever sent, never received.
    bool parse(const QDomElement &) { return false; }
    void toXml(XmlWriter &w) const
    {
        w.write(Element { XmlTag,
                          Element {
                              u"item",
                              Attribute { u"jid", jid },
                              report } });
    }
};

// Manager data
struct QXmppBlockingManagerPrivate {
    std::optional<QList<QString>> blocklist;
    std::vector<QXmppPromise<QXmppBlockingManager::BlocklistResult>> openFetchBlocklistPromises;
};

/*!
    \class QXmppBlockingManager
    \inmodule QXmpp

    \brief Uses \xep{0191}{Blocking Command} to manage blocked accounts and services.

    \section1 Use Cases

    \list
        \li listing blocked devices, accounts and servers
        \li blocking and unblocking JIDs
        \li getting notified when a new JID has been blocked or unblocked
    \endlist

    \section2 Listing blocked devices

    You can receive a list of blocked JIDs by using fetchBlocklist().
    \code
    manager->fetchBlocklist().then(this, [](auto result) {
    if (auto *blocklist = std::get_if<QXmppBlocklist>(&result)) {
    qDebug() << "Blocked JIDs:" << blocklist->entries();
    } else if (auto *err = std::get_if<QXmppError>(&result)) {
    qDebug() << "Error fetching blocklist:" << err->description;
    }
    });
    \endcode

    The server will send updates to us for the rest of the stream. You can listen to the updates by
    connecting to blocked() and unblocked().

    \note The manager caches the blocklist, so after the first time the task will finish instantly.

    \section2 Blocking and Unblocking

    You can use block() and unblock() for this purpose.
    \code
    manager->block("baduser@spam.im").then(this, [](auto result) {
    if (QXmpp::hasValue(result)) {
    qDebug() << "Blocked baduser@spam.im!";
    } else if (auto *err = std::get_if<QXmppError>(&result)) {
    qDebug() << "Error:" << err->description;
    }
    });
    \endcode
    unblock() works likewise.

    \note This will also trigger blocked() or unblocked() if you are subscribed to the blocklist.

    \section2 Blocklist Subscription

    You will automatically receive blocklist updates after you requested the blocklist. You can
    connect to the blocked() and unblocked() signals.

    \section1 Format

    It is important to notice that the blocked JIDs are not limited to accounts, allowed are the
    following formats:
    \list
        \li \c {user@domain/resource}
        \li \c {user@domain}
        \li \c {domain/resource}
        \li \c {domain}
    \endlist

    It is not possible to block a domain without blocking a specific account though (or another
    combination).

    \section1 Setup

    The blocking manager is not enabled by default and needs to be registered with your
    QXmppClient.
    \code
    auto *blockingManager = client->addNewExtension<QXmppBlockingManager>();
    \endcode

    \ingroup Managers
    \sa QXmppBlocklist
    \since QXmpp 1.6
*/

/*!
    \typedef QXmppBlockingManager::BlocklistResult

    Contains a QXmppBlocklist or an error.
*/

/*!
    \typedef QXmppBlockingManager::Result

    Contains QXmpp::Success or an error.
*/

/*!
    \fn QXmppBlockingManager::subscribedChanged()

    Called whenever the state of the subscription to blocklist updates has changed.
*/

/*!
    \fn void QXmppBlockingManager::blocked(const QList<QString> &jids)

    Emitted when a blocklist update with new blocked JIDs \a jids has been received.

    This is also emitted when you call block().
*/

/*!
    \fn void QXmppBlockingManager::unblocked(const QList<QString> &jids)

    Emitted when a blocklist update with new unblocked JIDs \a jids has been received.

    This is also emitted when you call unblock().
*/

// Manager
QXmppBlockingManager::QXmppBlockingManager()
    : d(new QXmppBlockingManagerPrivate)
{
}

QXmppBlockingManager::~QXmppBlockingManager() = default;

/*!
    \brief Returns whether the blocking manager currently receives updates of the blocklist.

    The subscription is enabled automatically after fetching the blocklist using fetchBlocklist().
*/
bool QXmppBlockingManager::isSubscribed() const
{
    return d->blocklist.has_value();
}

/*!
    \brief Fetches the list of blocked JIDs and subscribes to blocklist updates.

    The manager will cache the blocklist and keep track of updates for the rest of the session.
    Later calls of this function will report the cached result immediately. Even calling this
    function multiple times before the first request has finished, will not trigger more than one
    IQ request being sent.
*/
QXmppTask<QXmppBlockingManager::BlocklistResult> QXmppBlockingManager::fetchBlocklist()
{
    // use cached blocklist if possible
    if (d->blocklist) {
        return makeReadyTask<BlocklistResult>(QXmppBlocklist(d->blocklist.value()));
    }

    // This function is designed so that you can call it multiple times and the actual IQ request
    // to the server is only done once.
    //
    // If there's no data yet, we cache all open promises. When the IQ request finishes, the result
    // is reported to all promises.

    // Create promise and cache it
    QXmppPromise<BlocklistResult> promise;
    auto task = promise.task();

    d->openFetchBlocklistPromises.push_back(std::move(promise));

    // send IQ request, if this is the first request
    if (d->openFetchBlocklistPromises.size() == 1) {
        auto iq = CompatIq {
            GetIq<Blocking<List>> {
                generateSequentialStanzaId(),
            },
        };
        client()->sendIq(std::move(iq)).then(this, [this](QXmppClient::IqResult &&result) {
            // parse into QXmppBlocklist/Error variant
            auto blocklistResult = map<BlocklistResult>(
                [](Blocking<List> &&list) { return QXmppBlocklist(list.jids); },
                parseIqResponseFlat<Blocking<List>>(std::move(result)));

            // store blocklist on success
            if (!d->blocklist) {
                if (auto *blocklist = std::get_if<QXmppBlocklist>(&blocklistResult)) {
                    d->blocklist = blocklist->entries();
                    Q_EMIT subscribedChanged();
                }
            }

            // report result to all promises
            for (auto &promise : d->openFetchBlocklistPromises) {
                auto copy = blocklistResult;
                promise.finish(std::move(copy));
            }
            // delete cached promises
            d->openFetchBlocklistPromises.clear();
        });
    }

    return task;
}

/*!
    \fn QXmppBlockingManager::block(QString jid)

    Blocks the JID \a jid.

    \sa unblock()
*/

/*!
    Blocks a list of JIDs.

    \sa unblock()
*/
QXmppTask<QXmppBlockingManager::Result> QXmppBlockingManager::block(QList<QString> jids)
{
    return client()->sendGenericIq(CompatIq {
        SetIq<Blocking<Block>> {
            generateSequentialStanzaId(), {}, {}, {}, { std::move(jids) } },
    });
}

/*!
    Blocks the JID \a jid and attaches a \xep{0377}{Blocking Command Reports} \a report.

    The JID is blocked just like with block(); the report is only processed if the server
    advertises the \c urn:xmpp:reporting:1 feature in its service discovery information.
    Otherwise the JID is still blocked, but the report is silently ignored.

    \since QXmpp 1.17
    \sa block(), QXmppSpamReport
*/
QXmppTask<QXmppBlockingManager::Result> QXmppBlockingManager::reportAndBlock(QString jid, QXmppSpamReport report)
{
    return client()->sendGenericIq(CompatIq {
        SetIq<BlockReport> {
            generateSequentialStanzaId(), {}, {}, {}, { std::move(jid), std::move(report) } },
    });
}

/*!
    \fn QXmppBlockingManager::unblock(QString jid)

    Unblocks the JID \a jid.

    \sa block()
*/

/*!
    Unblocks a list of JIDs.

    \sa block()
*/
QXmppTask<QXmppBlockingManager::Result> QXmppBlockingManager::unblock(QList<QString> jids)
{
    return client()->sendGenericIq(CompatIq {
        SetIq<Blocking<Unblock>> {
            generateSequentialStanzaId(), {}, {}, {}, { std::move(jids) } },
    });
}

QStringList QXmppBlockingManager::discoveryFeatures() const
{
    return { staticString(ns_blocking) };
}

void QXmppBlockingManager::onRegistered(QXmppClient *client)
{
    connect(client, &QXmppClient::connected, this, &QXmppBlockingManager::onConnected);
}

void QXmppBlockingManager::onUnregistered(QXmppClient *oldClient)
{
    disconnect(oldClient, &QXmppClient::connected, this, &QXmppBlockingManager::onConnected);
}

bool QXmppBlockingManager::handleStanza(const QDomElement &stanza, const std::optional<QXmppE2eeMetadata> &)
{
    auto checkIqValidity = [this](QStringView from) -> std::optional<StanzaError> {
        // check permissions
        // only server (user's account) is allowed to do blocklist pushes
        if (!from.isEmpty() && from != client()->configuration().jidBare()) {
            // deny
            return StanzaError {
                StanzaError::Cancel,
                StanzaError::Forbidden,
                u"Forbidden."_s
            };
        }

        if (!d->blocklist) {
            return StanzaError {
                StanzaError::Wait,
                StanzaError::UnexpectedRequest,
                u"Client is not subscribed to blocklist."_s
            };
        }

        // allow
        return {};
    };

    auto handleBlock = [this, checkIqValidity](SetIq<Blocking<Block>> &&iq) -> std::variant<QXmppIq, StanzaError> {
        if (auto err = checkIqValidity(iq.from)) {
            return std::move(*err);
        }

        // store new jids
        d->blocklist->append(iq.payload.jids);
        // assure blocklist has no duplicates
        makeUnique(*d->blocklist);

        Q_EMIT blocked(iq.payload.jids);
        return QXmppIq(QXmppIq::Result);
    };
    auto handleUnblock = [this, checkIqValidity](SetIq<Blocking<Unblock>> &&iq) -> std::variant<QXmppIq, StanzaError> {
        if (auto err = checkIqValidity(iq.from)) {
            return std::move(*err);
        }

        // remove jids
        for (const auto &jid : std::as_const(iq.payload.jids)) {
            d->blocklist->removeOne(jid);
        }

        Q_EMIT unblocked(iq.payload.jids);
        return QXmppIq(QXmppIq::Result);
    };

    // e2ee is not supported (not needed with local server)
    return handleIqRequests<SetIq<Blocking<Block>>, SetIq<Blocking<Unblock>>>(stanza, client(), overloaded { handleBlock, handleUnblock });
}

void QXmppBlockingManager::onConnected()
{
    if (d->blocklist && client()->streamManagementState() != QXmppClient::ResumedStream) {
        d->blocklist.reset();
        Q_EMIT subscribedChanged();
    }
}

/*!
    \class QXmppBlocklist
    \inmodule QXmpp

    \brief List of blocked entries according to \xep{0191}{Blocking Command} with helper functions
    to check the blocking state of JIDs.

    \sa QXmppBlockingManager
    \since QXmpp 1.6
*/

/*!
    \typedef QXmppBlocklist::BlockingState

    Whether a JID is completely blocked (Blocked), partially blocked (PartiallyBlocked) or not
    blocked (NotBlocked).
*/

QXmppBlocklist::QXmppBlocklist() = default;

/*! Constructs with given entries. */
QXmppBlocklist::QXmppBlocklist(QList<QString> entries)
    : m_blocklist(std::move(entries))
{
}

QXMPP_PRIVATE_DEFINE_RULE_OF_SIX(QXmppBlocklist)

/*!
    Returns a list of blocked entires.

    Entries may be full JIDs, bare JIDs, domains or domains with resource, as in
    \xep{0191}{Blocking Command}.
*/
QList<QString> QXmppBlocklist::entries() const
{
    return m_blocklist;
}

/*!
    Returns true if the blocklist contains the entry \a entry.

    \note This does not check whether a JID may be blocked or blocked partially by other entries.
    E.g. \c {containsEntry("user@domain.tld")} will return false even if \c {domain.tld} is blocked
    completely.
*/
bool QXmppBlocklist::containsEntry(QStringView entry) const
{
    // Can be replaced with .contains() with Qt 6
    return std::find(m_blocklist.begin(), m_blocklist.end(), entry) != m_blocklist.end();
}

/*!
    Checks the blocking state of a JID.

    A JID can be a full JID, a bare JID, a domain or a domain with a resource, as in
    \xep{0191}{Blocking Command}.

    Returns the BlockingState of the JID.

    \a jid.
*/
QXmppBlocklist::BlockingState QXmppBlocklist::blockingState(const QString &jid) const
{
    auto user = QXmppUtils::jidToUser(jid);
    auto domain = QXmppUtils::jidToDomain(jid);
    auto resource = QXmppUtils::jidToResource(jid);

    Q_ASSERT(!jid.isEmpty());
    Q_ASSERT(!domain.isEmpty());

    // determine type of given JID
    enum { FullJid,
           BareJid,
           Domain,
           DomainResource } jidType = [&] {
        // domain is always set
        if (user.isEmpty()) {
            // no user set
            if (!resource.isEmpty()) {
                return DomainResource;
            }
            return Domain;
        }
        // user is set
        if (!resource.isEmpty()) {
            return FullJid;
        }
        return BareJid;
    }();

    // cause the given jid to be blocked (completely)
    QList<QString> blockingJids;
    // cause parts of the given JID to be blocked
    QList<QString> partiallyBlockingJids;

    auto checkBlockingJid = [this, &blockingJids](const QString &jid) {
        if (m_blocklist.contains(jid)) {
            blockingJids.append(jid);
        }
    };
    auto checkPartiallyBlockingJid = [this, &partiallyBlockingJids](const QString &jid) {
        if (m_blocklist.contains(jid)) {
            partiallyBlockingJids.append(jid);
        }
    };

    switch (jidType) {
    case FullJid:
        // Blocking:
        //  * full jid
        //  * bare jid
        //  * domain
        //  * domain + resource
        // Partially blocked:
        //  not possible
        checkBlockingJid(jid);
        checkBlockingJid(user + u'@' + domain);
        checkBlockingJid(domain);
        checkBlockingJid(domain + u'/' + resource);
        break;
    case BareJid: {
        // Blocking:
        //  * bare jid
        //  * domain
        // Partially blocking:
        //  * full jids
        //  * domain resource
        checkBlockingJid(jid);
        checkBlockingJid(domain);

        // look for full jids blocking the bare jid partially
        QString fullJidStart = jid + u'/';
        for (const auto &blockedJid : m_blocklist) {
            if (blockedJid.startsWith(fullJidStart)) {
                partiallyBlockingJids.append(blockedJid);
            }
        }

        checkPartiallyBlockingJid(domain + u'/' + resource);
        break;
    }
    case Domain: {
        // Blocking:
        //  * domain
        // Partially blocking:
        //  * full jids
        //  * bare jids
        //  * domain + resource
        checkBlockingJid(jid);

        // look for full/bare jids and domain+resource jids
        QString userJidSubstring = u'@' + domain;
        QString domainResourceSubstring = domain + u'/';

        for (const auto &blockedJid : m_blocklist) {
            if (blockedJid.contains(userJidSubstring) || blockedJid.contains(domainResourceSubstring)) {
                partiallyBlockingJids.append(blockedJid);
            }
        }

        break;
    }
    case DomainResource: {
        // Blocking:
        //  * domain
        //  * domain + resource
        // Partially blocking:
        //  * full jid
        //  * bare jid
        checkBlockingJid(jid);
        checkBlockingJid(domain);

        // look for full/bare jids
        QString userJidSubstring = u'@' + domain;
        for (const auto &blockedJid : m_blocklist) {
            if (blockedJid.contains(userJidSubstring)) {
                partiallyBlockingJids.append(blockedJid);
            }
        }
        break;
    }
    }

    if (!blockingJids.isEmpty()) {
        return Blocked {
            blockingJids,
            partiallyBlockingJids,
        };
    }
    if (!partiallyBlockingJids.isEmpty()) {
        return PartiallyBlocked {
            partiallyBlockingJids
        };
    }
    return NotBlocked {};
}
