// 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;
};

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

///
/// \class QXmppJingleMessageInitiation
///
/// \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".
///
/// \param reason Reason object for reject element
/// \param containsTieBreak 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);

    d->request(std::move(jmiElement)).then(this, [this, 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 promise.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".
///
/// \param reason Reason object for retract element
/// \param containsTieBreak 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);

    d->request(std::move(jmiElement)).then(this, [this, 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 promise.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".
///
/// \param reason Reason object for finish element
/// \param migratedTo 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);

    d->request(std::move(jmiElement)).then(this, [this, 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 promise.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.
///
/// \param remoteJid 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.
///
/// \param id belonging JMI id
/// \param remoteResource 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.
///
/// \param result 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
///
/// \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;

/// \cond
QStringList QXmppJingleMessageInitiationManager::discoveryFeatures() const
{
    return { ns_jingle_message.toString() };
}
/// \endcond

///
/// 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
///
QXmppTask<QXmppJingleMessageInitiationManager::ProposeResult> QXmppJingleMessageInitiationManager::propose(const QString &remoteJid, const QList<QXmppJingleRtpDescription> &descriptions)
{
    return propose(QXmppUtils::generateStanzaUuid(), remoteJid, descriptions);
}

QXmppTask<QXmppJingleMessageInitiationManager::ProposeResult> QXmppJingleMessageInitiationManager::propose(const QString &id, const QString &remoteJid, const QList<QXmppJingleRtpDescription> &descriptions)
{
    QXmppPromise<ProposeResult> promise;

    QXmppJingleMessageInitiationElement jmiElement;
    jmiElement.setType(JmiType::Propose);
    jmiElement.setId(id);
    jmiElement.setDescriptions(descriptions);

    sendMessage(jmiElement, remoteJid).then(this, [this, promise, id, remoteJid](SendResult result) mutable {
        if (hasError(result)) {
            warning(u"Error sending Jingle Message Initiation proposal: " + getError(result).description);
            promise.finish(getError(std::move(result)));
        } else {
            promise.finish(addJmi(id, remoteJid));
        }
    });

    return promise.task();
}

/// \cond
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;
}
/// \endcond

///
/// Lets the client send a message to user with given remoteJid containing the JMI element.
///
/// \param jmiElement the JMI element to be passed
/// \param remoteJid 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 a JMI object from the JMIs vector.
///
/// \param jmi object to be removed
///
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.
///
/// \param existingJmi JMI object to be handled
/// \param jmiElement JMI element to be processed with the JMI object
/// \param remoteResource resource of the call partner (i.e., phone, tablet etc.)
/// \return 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.
///
/// \param jmiElement to be handled
/// \param remoteJid bare JID of the call partner
/// \return 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.
/// \param existingJmi existing JMI object to be handled
/// \param jmiElement JMI element to be processed with existing JMI object
/// \param remoteResource resource of the call partner (i.e., phone, tablet etc.)
/// \return 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.
///
/// \param existingJmi Current JMI object
/// \param jmiElementId 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;
}

///
/// \brief 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.
///
/// \param existingJmi Current JMI object
/// \param jmiElementId 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.
/// \param remoteJid bare JID of the call partner
/// \return 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 QXmppJingleMessageInitiationManager::proposed(const std::shared_ptr<QXmppJingleMessageInitiation> &, const QString &, const QXmppJingleDescription &)
///
/// Emitted when a call has been proposed.
///
/// \param jmi Jingle Message Initiation object of proposed session
/// \param id JMI element id
/// \param description JMI element's description containing media type (i.e., audio, video)
///
/// \deprecated
///

///
/// \fn QXmppJingleMessageInitiationManager::proposeReceived(const std::shared_ptr<QXmppJingleMessageInitiation> &, const QString &, const QList<QXmppJingleDescription> &)
///
/// Emitted when a call has been proposed.
///
/// \param jmi Jingle Message Initiation object of proposed session
/// \param id JMI element id
/// \param descriptions JMI element's description containing media type (i.e., audio, video)
///
/// \since QXmpp 1.14
///
