// SPDX-FileCopyrightText: 2023 Tibor Csötönyi <work@taibsu.de>
// SPDX-FileCopyrightText: 2023 Melvin Keskin <melvo@olomono.de>
//
// SPDX-License-Identifier: LGPL-2.1-or-later

#include "QXmppJingleMessageInitiationManager.h"

#include "QXmppClient.h"
#include "QXmppConstants_p.h"
#include "QXmppMessage.h"
#include "QXmppPromise.h"
#include "QXmppUtils.h"

#include "StringLiterals.h"

#include <QUuid>

using namespace QXmpp;
using namespace QXmpp::Private;

using Jmi = QXmppJingleMessageInitiation;
using JmiManager = QXmppJingleMessageInitiationManager;
using JmiElement = QXmppJingleMessageInitiationElement;
using JmiType = JmiElement::Type;

class QXmppJingleMessageInitiationPrivate
{
public:
    QXmppJingleMessageInitiationPrivate(JmiManager *manager, const QString &id, const QString &remoteJid) : manager(manager), id(id), remoteJid(remoteJid) { }

    QXmppTask<SendResult> request(JmiElement &&jmiElement);

    QXmppJingleMessageInitiationManager *manager;
    QString id;
    QString remoteJid;
    bool proceeded = false;
    bool finished = false;
};

// Creates a Jingle Message Initiation request based on the given type of
// jmiElement (proceed, accept, reject, retract, finish).
QXmppTask<SendResult> QXmppJingleMessageInitiationPrivate::request(JmiElement &&jmiElement)
{
    jmiElement.setId(id);
    return manager->sendMessage(jmiElement, remoteJid);
}

/*!
    \class QXmppJingleMessageInitiation
    \inmodule QXmpp

    \brief The QXmppJingleMessageInitiation class holds information about the JMI element in the
    current context.

    \since QXmpp 1.6
*/

/*! \brief Constructs a Jingle Message Initiation object. */
QXmppJingleMessageInitiation::QXmppJingleMessageInitiation(QXmppJingleMessageInitiationManager *manager, const QString &id, const QString &remoteJid)
    : d(new QXmppJingleMessageInitiationPrivate(manager, id, remoteJid))
{
}

QXmppJingleMessageInitiation::~QXmppJingleMessageInitiation() = default;

/*! Creates a JMI element of type "ringing" and sends a request containing the element. */
QXmppTask<SendResult> QXmppJingleMessageInitiation::ring()
{
    QXmppJingleMessageInitiationElement jmiElement;
    jmiElement.setType(JmiType::Ringing);

    return d->request(std::move(jmiElement));
}

/*! Creates a JMI element of type "proceed" and sends a request containing the element. */
QXmppTask<SendResult> QXmppJingleMessageInitiation::proceed()
{
    QXmppJingleMessageInitiationElement jmiElement;
    jmiElement.setType(JmiType::Proceed);
    jmiElement.setId(d->id);
    return d->request(std::move(jmiElement));
}

/*!
    Creates a JMI element of type "reject" and sends a request containing the element.
    The default reason tag/type will be "busy" with text "Busy".

    \a reason is the reason object for the reject element. \a containsTieBreak
    specifies whether the reject element contains a tie-break tag or not.
*/
QXmppTask<SendResult> QXmppJingleMessageInitiation::reject(std::optional<QXmppJingleReason> reason, bool containsTieBreak)
{
    QXmppPromise<SendResult> promise;

    JmiElement jmiElement;
    jmiElement.setType(JmiType::Reject);

    if (!reason) {
        reason = QXmppJingleReason();
        reason->setType(QXmppJingleReason::Busy);
        reason->setText(u"Busy"_s);
    }

    jmiElement.setReason(reason);
    jmiElement.setContainsTieBreak(containsTieBreak);

    auto task = promise.task();
    d->request(std::move(jmiElement)).then(this, [this, promise = std::move(promise), containsTieBreak](SendResult &&result) mutable {
        // Tie-break rejections don't close the local session; only emit closed() for real rejections.
        if (!containsTieBreak) {
            if (hasError(result)) {
                Q_EMIT closed(getError(result));
            } else {
                Q_EMIT closed(Rejected {});
            }
        }

        promise.finish(std::move(result));
    });

    return task;
}

/*!
    Creates a JMI element of type "retract" and sends a request containing the element.
    The default reason tag/type will be "cancel" with text "Retracted".

    \a reason is the reason object for the retract element. \a containsTieBreak
    specifies whether the retract element contains a tie-break tag or not.
*/
QXmppTask<SendResult> QXmppJingleMessageInitiation::retract(std::optional<QXmppJingleReason> reason, bool containsTieBreak)
{
    QXmppPromise<SendResult> promise;

    JmiElement jmiElement;
    jmiElement.setType(JmiType::Retract);

    if (!reason) {
        reason = QXmppJingleReason();
        reason->setType(QXmppJingleReason::Cancel);
        reason->setText(u"Retracted"_s);
    }

    jmiElement.setReason(reason);
    jmiElement.setContainsTieBreak(containsTieBreak);

    auto task = promise.task();
    d->request(std::move(jmiElement)).then(this, [this, promise = std::move(promise), containsTieBreak](SendResult &&result) mutable {
        // Tie-break retractions don't close the local session; only emit closed() for real retractions.
        if (!containsTieBreak) {
            if (hasError(result)) {
                Q_EMIT closed(getError(result));
            } else {
                Q_EMIT closed(Retracted {});
            }
        }

        promise.finish(std::move(result));
    });

    return task;
}

/*!
    Creates a JMI element of type "finish" and sends a request containing the element.
    The default reason type/tag will be "success" with text "Success".

    \a reason is the reason object for the finish element. \a migratedTo is the
    JMI id the session has been migrated to.
*/
QXmppTask<SendResult> QXmppJingleMessageInitiation::finish(std::optional<QXmppJingleReason> reason, const QString &migratedTo)
{
    QXmppPromise<SendResult> promise;

    d->finished = true;

    JmiElement jmiElement;
    jmiElement.setType(JmiType::Finish);

    if (!reason) {
        reason = QXmppJingleReason();
        reason->setType(QXmppJingleReason::Success);
        reason->setText(u"Success"_s);
    }

    jmiElement.setReason(reason);
    jmiElement.setMigratedTo(migratedTo);

    auto task = promise.task();
    d->request(std::move(jmiElement)).then(this, [this, promise = std::move(promise), reason, migratedTo](SendResult &&result) mutable {
        if (hasError(result)) {
            Q_EMIT closed(getError(result));
        } else {
            Q_EMIT closed(Finished { reason, migratedTo });
        }

        promise.finish(std::move(result));
    });

    return task;
}

/*! Returns the JMI ID. */
QString QXmppJingleMessageInitiation::id() const
{
    return d->id;
}

/*! Sets the JMI ID. */
void QXmppJingleMessageInitiation::setId(const QString &id)
{
    d->id = id;
}

/*!
    Sets the call partner's bare JID.

    Normally, the JMI ID would be sufficient in order to differentiate the JMIs.
    However, attackers pretending to be the call partner can be mitigated by caching the call
    partner's JID.

    \a remoteJid is the bare JID of the call partner.
*/
void QXmppJingleMessageInitiation::setRemoteJid(const QString &remoteJid)
{
    d->remoteJid = remoteJid;
}

/*!
    Returns the call partner's bare JID.

    \since QXmpp 1.14
*/
const QString &QXmppJingleMessageInitiation::remoteJid() const
{
    return d->remoteJid;
}

/*! Returns the "proceeded" flag, e.g., if the Jingle Message Initiation has already been proceeded. */
bool QXmppJingleMessageInitiation::isProceeded() const
{
    return d->proceeded;
}

/*! Sets the "proceeded" flag, e.g., if the Jingle Message Initiation has already been proceeded. */
void QXmppJingleMessageInitiation::setIsProceeded(bool isProceeded)
{
    d->proceeded = isProceeded;
}

/*!
    Returns whether the call is finished.

    This is needed to avoid a loop while responding to a </finish> element with the same one.
*/
bool QXmppJingleMessageInitiation::isFinished() const
{
    return d->finished;
}

/*!
    \fn QXmppJingleMessageInitiation::ringing()

    Emitted when a propose request was accepted and the device starts ringing.
*/

/*!
    \fn QXmppJingleMessageInitiation::proceeded(const QString &, const QString &)

    Emitted when a propose request was successfully processed and accepted.

    \a id is the belonging JMI id. \a remoteResource is the resource of the call
    partner about to be called.
*/

/*!
    \fn QXmppJingleMessageInitiation::closed(const Result &)

    Emitted when a call was ended either through rejection, retraction, finish or an error.

    \a result is the close reason.
*/

class QXmppJingleMessageInitiationManagerPrivate
{
public:
    QVector<std::shared_ptr<Jmi>> jmis;
};

/*!
    \typedef QXmppJingleMessageInitiationManager::ProposeResult

    Contains JMI object or an error if sending the propose message failed.
*/

/*!
    \class QXmppJingleMessageInitiationManager
    \inmodule QXmpp
    \ingroup Managers

    \brief The QXmppJingleMessageInitiationManager class makes it possible to retrieve
    Jingle Message Initiation elements as defined by \xep{0353}{Jingle Message Initiation}.

    \since QXmpp 1.6
*/
QXmppJingleMessageInitiationManager::QXmppJingleMessageInitiationManager()
    : d(std::make_unique<QXmppJingleMessageInitiationManagerPrivate>())
{
}

QXmppJingleMessageInitiationManager::~QXmppJingleMessageInitiationManager() = default;

QStringList QXmppJingleMessageInitiationManager::discoveryFeatures() const
{
    return { staticString(ns_jingle_message) };
}

/*!
    Creates a proposal JMI element and passes it as a message.

    \overload
*/
QXmppTask<QXmppJingleMessageInitiationManager::ProposeResult> QXmppJingleMessageInitiationManager::propose(const QString &remoteJid, const QXmppJingleRtpDescription &description)
{
    return propose(QXmppUtils::generateStanzaUuid(), remoteJid, QList<QXmppJingleRtpDescription> { description });
}

/*!
    Creates a proposal JMI element and passes it as a message.

    \since QXmpp 1.14

    \a descriptions and \a remoteJid.
*/
QXmppTask<QXmppJingleMessageInitiationManager::ProposeResult> QXmppJingleMessageInitiationManager::propose(const QString &remoteJid, const QList<QXmppJingleRtpDescription> &descriptions)
{
    return propose(QXmppUtils::generateStanzaUuid(), remoteJid, descriptions);
}

QXmppTask<QXmppJingleMessageInitiationManager::ProposeResult> QXmppJingleMessageInitiationManager::propose(QString id, QString remoteJid, const QList<QXmppJingleRtpDescription> &descriptions)
{
    QXmppJingleMessageInitiationElement jmiElement;
    jmiElement.setType(JmiType::Propose);
    jmiElement.setId(id);
    jmiElement.setDescriptions(descriptions);

    auto result = co_await sendMessage(jmiElement, remoteJid).withContext(this);
    if (hasError(result)) {
        warning(u"Error sending Jingle Message Initiation proposal: " + getError(result).description);
        co_return getError(std::move(result));
    } else {
        co_return addJmi(id, remoteJid);
    }
}

bool QXmppJingleMessageInitiationManager::handleMessage(const QXmppMessage &message)
{
    if (message.type() != QXmppMessage::Chat) {
        return false;
    }

    // Only continue if the message contains a JMI element.
    if (auto jmiElement = message.jingleMessageInitiationElement()) {
        return handleJmiElement(std::move(*jmiElement), message.from());
    }

    return false;
}

/*!
    Lets the client send a message to the user with the given \a remoteJid
    containing the JMI element \a jmiElement. \a remoteJid is the bare JID of
    the call partner.
*/
QXmppTask<SendResult> QXmppJingleMessageInitiationManager::sendMessage(const QXmppJingleMessageInitiationElement &jmiElement, const QString &remoteJid)
{
    QXmppMessage message;
    message.setTo(remoteJid);
    message.addHint(QXmppMessage::Store);
    message.setJingleMessageInitiationElement(jmiElement);

    return client()->send(std::move(message));
}

/*!
    Removes the JMI object \a jmi from the JMIs vector.
*/
void QXmppJingleMessageInitiationManager::clear(const std::shared_ptr<QXmppJingleMessageInitiation> &jmi)
{
    d->jmis.erase(
        std::remove_if(
            d->jmis.begin(),
            d->jmis.end(),
            [&jmi](const auto &storedJmi) {
                return jmi->id() == storedJmi->id() && jmi->remoteJid() == storedJmi->remoteJid();
            }),
        d->jmis.end());
}

/*! Removes all JMI objects from the JMI vector. */
void QXmppJingleMessageInitiationManager::clearAll()
{
    d->jmis.clear();
}

bool QXmppJingleMessageInitiationManager::handleJmiElement(QXmppJingleMessageInitiationElement &&jmiElement, const QString &remoteJid)
{
    auto jmiElementId = jmiElement.id();

    if (jmiElementId.isEmpty()) {
        warning(u"Received JMI element with empty ID from %1. (ignoring)"_s.arg(remoteJid));
        return false;
    }

    // Check if there's already a JMI object with jmiElementId and remoteJid in JMIs vector.
    // That means that a JMI has already been created with given (J)IDs.
    auto itr = std::find_if(d->jmis.begin(), d->jmis.end(), [&jmiElementId, &remoteJid](const auto &jmi) {
        return jmi->id() == jmiElementId && QXmppUtils::jidToBareJid(jmi->remoteJid()) == QXmppUtils::jidToBareJid(remoteJid);
    });

    if (itr != d->jmis.end()) {
        return handleExistingJmi(*itr, jmiElement, QXmppUtils::jidToResource(remoteJid));
    }

    if (jmiElement.type() == JmiType::Propose) {
        return handleProposeJmiElement(jmiElement, remoteJid);
    }

    return false;
}

// Handles a JMI object which already exists in the JMIs vector.
//
// existingJmi is the JMI object to be handled. jmiElement is the JMI element to
// be processed with the JMI object. remoteResource is the resource of the call
// partner (i.e., phone, tablet etc.).
//
// Returns success (true) or failure.
bool QXmppJingleMessageInitiationManager::handleExistingJmi(const std::shared_ptr<Jmi> &existingJmi, const QXmppJingleMessageInitiationElement &jmiElement, const QString &remoteResource)
{
    switch (jmiElement.type()) {
    case JmiType::Ringing:
        Q_EMIT existingJmi->ringing();
        return true;
    case JmiType::Proceed:
        Q_EMIT existingJmi->proceeded(jmiElement.id(), remoteResource);
        existingJmi->setIsProceeded(true);
        return true;
    case JmiType::Reject:
        Q_EMIT existingJmi->closed(
            Jmi::Rejected { jmiElement.reason(), jmiElement.containsTieBreak() });
        return true;
    case JmiType::Retract:
        Q_EMIT existingJmi->closed(
            Jmi::Retracted { jmiElement.reason(), jmiElement.containsTieBreak() });
        return true;
    case JmiType::Finish:
        if (!existingJmi->isFinished()) {
            Q_EMIT existingJmi->closed(
                Jmi::Finished { jmiElement.reason(), jmiElement.migratedTo() });

            // XEP-0353 §3.7: both parties SHOULD send <finish/> to synchronize
            // session state across all devices. Send our own <finish/> back
            // directly (not via finish() which would re-emit closed()).
            QXmppJingleMessageInitiationElement finishElement;
            finishElement.setType(JmiType::Finish);
            finishElement.setId(existingJmi->id());
            QXmppJingleReason successReason;
            successReason.setType(QXmppJingleReason::Success);
            finishElement.setReason(successReason);
            sendMessage(finishElement, existingJmi->remoteJid());
        }
        return true;
    default:
        return false;
    }
}

/*!
    Handles a propose JMI element.

    \a jmiElement is the propose JMI element to be handled. \a remoteJid is the
    bare JID of the call partner.

    Returns success (true) or failure.
*/
bool QXmppJingleMessageInitiationManager::handleProposeJmiElement(const QXmppJingleMessageInitiationElement &jmiElement, const QString &remoteJid)
{
    // Check if there's already a JMI object with provided remoteJid in JMIs vector.
    // That means that a propose has already been sent.
    auto itr = std::find_if(
        d->jmis.cbegin(),
        d->jmis.cend(),
        [&remoteJid](const auto &jmi) {
            return jmi->remoteJid() == remoteJid;
        });

    // Tie break case or usual JMI proposal?
    if (itr != d->jmis.end()) {
        return handleTieBreak(*itr, jmiElement, remoteJid);
    }

    auto jmi = addJmi(jmiElement.id(), remoteJid);
    Q_EMIT proposed(jmi, jmiElement.id(), jmiElement.description());
    Q_EMIT proposeReceived(jmi, jmiElement.id(), jmiElement.descriptions());
    return true;
}

// Handles a tie break case as defined in
// https://xmpp.org/extensions/xep-0353.html#tie-breaking.
//
// existingJmi is the existing JMI object to be handled. jmiElement is the JMI
// element to be processed with the existing JMI object. remoteResource is the
// resource of the call partner (i.e., phone, tablet etc.).
//
// Returns success (true) or failure.
bool QXmppJingleMessageInitiationManager::handleTieBreak(const std::shared_ptr<Jmi> &existingJmi, const QXmppJingleMessageInitiationElement &jmiElement, const QString &remoteResource)
{
    // Tie break -> session is set to be expired
    QXmppJingleReason reason;
    reason.setType(QXmppJingleReason::Expired);

    // Existing (proceeded) or non-existing session?
    if (existingJmi->isProceeded()) {
        return handleExistingSession(existingJmi, jmiElement.id());
    }

    // Tie break in propose state (no existing session) - two parties try calling each other
    // at the same time, the proposal with the lower ID overrules the other one.
    return handleNonExistingSession(existingJmi, jmiElement.id(), remoteResource);
}

// Device switch: session already exists and will be migrated to new device with
// id jmiElementId.
//
// existingJmi is the current JMI object. jmiElementId is the new (counterpart's)
// session JMI element ID.
bool QXmppJingleMessageInitiationManager::handleExistingSession(const std::shared_ptr<Jmi> &existingJmi, const QString &jmiElementId)
{
    // Old session will be finished with reason "Expired".
    QXmppJingleReason reason;
    reason.setType(QXmppJingleReason::Expired);
    reason.setText(u"Session migrated"_s);

    existingJmi->finish(reason, jmiElementId).then(this, [this, existingJmi, jmiElementId](SendResult result) {
        if (!hasError(result)) {
            // Then, proceed (accept) the new proposal and set the JMI ID
            // to the ID of the received JMI element.
            existingJmi->setId(jmiElementId);
            existingJmi->proceed().then(this, [existingJmi](SendResult result) {
                if (hasError(result)) {
                    Q_EMIT existingJmi->closed(getError(std::move(result)));
                } else {
                    // The session is now closed as it is finished.
                    existingJmi->setIsProceeded(true);
                }
            });
        }
    });

    return true;
}

// Tie break in propose state (no existing session) - two parties try calling
// each other at the same time, the proposal with the lower ID overrules the
// other one.
//
// existingJmi is the current JMI object. jmiElementId is the counterpart's JMI
// element ID.
bool QXmppJingleMessageInitiationManager::handleNonExistingSession(const std::shared_ptr<Jmi> &existingJmi, const QString &jmiElementId, const QString &remoteResource)
{
    QXmppJingleReason reason;
    reason.setType(QXmppJingleReason::Expired);
    reason.setText(u"Tie-Break"_s);

    if (QUuid::fromString(existingJmi->id()) < QUuid::fromString(jmiElementId)) {
        // Jingle message initiator with lower ID rejects the other proposal.
        // The local session is not closed; the JMI remains active.
        existingJmi->setId(jmiElementId);
        existingJmi->reject(std::move(reason), true).then(this, [](auto) { });
    } else {
        // Jingle message initiator with higher ID retracts its proposal.
        existingJmi->retract(std::move(reason), true).then(this, [this, existingJmi, jmiElementId, remoteResource](auto result) {
            if (!hasError(result)) {
                // Afterwards, JMI ID is changed to lower ID.
                existingJmi->setId(jmiElementId);

                // Finally, the call is being accepted.
                existingJmi->proceed().then(this, [existingJmi, jmiElementId, remoteResource](SendResult result) {
                    if (hasError(result)) {
                        Q_EMIT existingJmi->closed(getError(std::move(result)));
                    } else {
                        existingJmi->setIsProceeded(true);
                        Q_EMIT existingJmi->proceeded(jmiElementId, remoteResource);
                    }
                });
            }
        });
    }

    return true;
}

/*!
    Adds a JMI object to the JMIs vector and sets the bare JID of the call
    partner in the JMI object. \a remoteJid is the bare JID of the call
    partner.

    Returns the newly created JMI.
*/
std::shared_ptr<QXmppJingleMessageInitiation> QXmppJingleMessageInitiationManager::addJmi(const QString &id, const QString &remoteJid)
{
    auto jmi = std::shared_ptr<QXmppJingleMessageInitiation>(new QXmppJingleMessageInitiation(this, id, remoteJid));

    connect(jmi.get(), &QXmppJingleMessageInitiation::closed, this, [this, jmi](const QXmppJingleMessageInitiation::Result &result) {
        // Don't remove the JMI on tie-break rejection/retraction since the local session persists.
        if (const auto *rejected = std::get_if<QXmppJingleMessageInitiation::Rejected>(&result)) {
            if (rejected->containsTieBreak) {
                return;
            }
        }
        if (const auto *retracted = std::get_if<QXmppJingleMessageInitiation::Retracted>(&result)) {
            if (retracted->containsTieBreak) {
                return;
            }
        }
        debug(u"JMI with ID '%1' for remote JID '%2' is closed and thus being removed"_s.arg(jmi->id(), jmi->remoteJid()));
        clear(jmi);
    });

    d->jmis.append(jmi);
    return jmi;
}

/*! Returns the JMIs vector. */
const QVector<std::shared_ptr<QXmppJingleMessageInitiation>> &QXmppJingleMessageInitiationManager::jmis() const
{
    return d->jmis;
}

/*!
    \fn void QXmppJingleMessageInitiationManager::proposed(const std::shared_ptr<QXmppJingleMessageInitiation> &jmi, const QString &id, const std::optional<QXmppJingleRtpDescription> &description)

    Emitted when a call has been proposed.

    \a jmi is the Jingle Message Initiation object of the proposed session.
    \a id is the JMI element id. \a description is the JMI element's
    description containing the media type (i.e., audio, video).

    \deprecated
*/

/*!
    \fn void QXmppJingleMessageInitiationManager::proposeReceived(const std::shared_ptr<QXmppJingleMessageInitiation> &jmi, const QString &id, const QList<QXmppJingleRtpDescription> &descriptions)

    Emitted when a call has been proposed.

    \a jmi is the Jingle Message Initiation object of the proposed session.
    \a id is the JMI element id. \a descriptions are the JMI element's
    descriptions containing the media type (i.e., audio, video).

    \since QXmpp 1.14
*/
