// SPDX-FileCopyrightText: 2019 Jeremy Lainé <jeremy.laine@m4x.org>
// SPDX-FileCopyrightText: 2019 Niels Ole Salscheider <ole@salscheider.org>
// SPDX-FileCopyrightText: 2025 Linus Jahn <lnj@kaidan.im>
//
// SPDX-License-Identifier: LGPL-2.1-or-later

#include "QXmppCall.h"

#include "QXmppCallManager.h"
#include "QXmppCallManager_p.h"
#include "QXmppCallStream.h"
#include "QXmppCallStream_p.h"
#include "QXmppCall_p.h"
#include "QXmppClient.h"
#include "QXmppConstants_p.h"
#include "QXmppJingleIq.h"
#include "QXmppStun.h"
#include "QXmppTask.h"
#include "QXmppUtils.h"

#include "Algorithms.h"
#include "Async.h"
#include "StringLiterals.h"

#include <chrono>

// gstreamer
#include <gst/gst.h>

#include <QDomElement>
#include <QTimer>

using namespace std::chrono_literals;
using namespace QXmpp::Private;

QXmppCallPrivate::QXmppCallPrivate(const QString &jid, const QString &sid, QXmppCall::Direction direction, QPointer<QXmppCallManager> manager, QXmppCall::State state, QXmppError &&error, QXmppCall *qq)
    : direction(direction),
      jid(jid),
      manager(manager),
      sid(sid),
      state(state),
      error(std::move(error)),
      q(qq)
{
    qRegisterMetaType<QXmppCall::State>();

    removeIf(videoCodecs, std::not_fn(isCodecSupported));
    removeIf(audioCodecs, std::not_fn(isCodecSupported));

    pipeline = gst_pipeline_new(nullptr);
    if (!pipeline) {
        qFatal("Failed to create pipeline");
        return;
    }
    rtpBin = gst_element_factory_make("rtpbin", nullptr);
    if (!rtpBin) {
        qFatal("Failed to create rtpbin");
        return;
    }
    // We do not want to build up latency over time
    g_object_set(rtpBin, "drop-on-latency", true, "async-handling", true, "latency", 25, "do-retransmission", true, nullptr);

    if (!gst_bin_add(GST_BIN(pipeline.get()), rtpBin)) {
        qFatal("Could not add rtpbin to the pipeline");
    }
    g_signal_connect_swapped(rtpBin, "pad-added",
                             G_CALLBACK(+[](QXmppCallPrivate *p, GstPad *pad) {
                                 p->padAdded(pad);
                             }),
                             this);
    g_signal_connect_swapped(rtpBin, "request-pt-map",
                             G_CALLBACK(+[](QXmppCallPrivate *p, uint sessionId, uint pt) {
                                 p->ptMap(sessionId, pt);
                             }),
                             this);
    g_signal_connect_swapped(rtpBin, "on-ssrc-active",
                             G_CALLBACK(+[](QXmppCallPrivate *p, uint sessionId, uint ssrc) {
                                 p->ssrcActive(sessionId, ssrc);
                             }),
                             this);

    if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
        qFatal("Unable to set the pipeline to the playing state");
        return;
    }
}

QXmppCallPrivate::~QXmppCallPrivate()
{
    if (gst_element_set_state(pipeline, GST_STATE_NULL) == GST_STATE_CHANGE_FAILURE) {
        qFatal("Unable to set the pipeline to the null state");
    }
    // Delete streams before pipeline.
    // Streams still need to be children of QXmppCall for logging to work.
    qDeleteAll(streams);
}

void QXmppCallPrivate::ssrcActive(uint sessionId, uint ssrc)
{
    Q_UNUSED(ssrc)
    GstElement *rtpSession;
    g_signal_emit_by_name(rtpBin, "get-session", static_cast<uint>(sessionId), &rtpSession);
    // TODO: implement bitrate controller
    // TODO: display stats like packet drop count
}

void QXmppCallPrivate::padAdded(GstPad *pad)
{
    auto padName = QString::fromUtf8(gst_pad_get_name(pad));
    auto nameParts = padName.split(u'_');
    if (nameParts.size() < 4) {
        return;
    }
    if (nameParts[0] == u"recv" ||
        nameParts[1] == u"rtp" ||
        nameParts[2] == u"src") {
        if (nameParts.size() != 6) {
            return;
        }

        int sessionId = nameParts[3].toInt();
        int pt = nameParts[5].toInt();
        auto *stream = find(streams, sessionId, &QXmppCallStream::id).value();

        // add decoder for codec
        if (stream->media() == VIDEO_MEDIA) {
            if (auto codec = find(videoCodecs, pt, &GstCodec::pt)) {
                stream->d->addDecoder(pad, *codec);
                q->debug(u"Receiving video from %1 using %2"_s
                             .arg(padName, codec->name));
            }
        } else if (stream->media() == AUDIO_MEDIA) {
            if (auto codec = find(audioCodecs, pt, &GstCodec::pt)) {
                qDebug() << "Adding audio decoder....";
                stream->d->addDecoder(pad, *codec);
                q->debug(u"Receiving audio from %1 using %2 (%3 channels, %4)"_s
                             .arg(padName,
                                  codec->name,
                                  QString::number(codec->channels),
                                  QString::number(codec->clockrate)));
            } else {
                q->warning(u"Error no decoder found for: " + padName + u" pt=" + QString::number(pt));
                q->warning(u"Available:"_s);
                for (const auto &c : std::as_const(audioCodecs)) {
                    q->warning(u"  " + QString::number(c.pt) + u' ' + c.name);
                }
            }
        }
    }
}

GstCaps *QXmppCallPrivate::ptMap(uint sessionId, uint pt)
{
    // generate caps for incoming stream by payload type id
    auto *stream = find(streams, sessionId, &QXmppCallStream::id).value();
    if (auto payloadType = find(stream->d->payloadTypes, pt, &QXmppJinglePayloadType::id)) {
        return gst_caps_new_simple("application/x-rtp",
                                   "media", G_TYPE_STRING, stream->media().toLatin1().data(),
                                   "clock-rate", G_TYPE_INT, payloadType->clockrate(),
                                   "encoding-name", G_TYPE_STRING, payloadType->name().toUpper().toLatin1().data(),
                                   nullptr);
    }
    q->warning(u"Remote party %1 transmits wrong %2 payload for call %3"_s.arg(jid, stream->media(), sid));
    return nullptr;
}

bool QXmppCallPrivate::isFormatSupported(const QString &codecName)
{
    return GstElementFactoryPtr(gst_element_factory_find(codecName.toLatin1().data())) != nullptr;
}

bool QXmppCallPrivate::isCodecSupported(const GstCodec &codec)
{
    return isFormatSupported(codec.gstPay) &&
        isFormatSupported(codec.gstDepay) &&
        isFormatSupported(codec.gstEnc) &&
        isFormatSupported(codec.gstDec);
}

bool QXmppCallPrivate::handleDescription(QXmppCallStream *stream, const QXmppJingleIq::Content &content)
{
    stream->d->payloadTypes = content.payloadTypes();
    auto it = stream->d->payloadTypes.begin();
    bool foundCandidate = false;
    while (it != stream->d->payloadTypes.end()) {
        bool dynamic = it->id() >= 96;
        bool supported = false;
        auto isVideoStream = stream->media() == VIDEO_MEDIA;

        if (!videoSupported && isVideoStream) {
            videoSupported = true;
        }

        auto &codecs = isVideoStream ? videoCodecs : audioCodecs;
        for (auto &codec : codecs) {
            if (dynamic) {
                if (codec.name == it->name() &&
                    codec.clockrate == it->clockrate() &&
                    codec.channels == it->channels()) {
                    if (!foundCandidate) {
                        stream->d->addEncoder(codec);
                        foundCandidate = true;
                    }
                    supported = true;
                    /* Adopt id from other side. */
                    codec.pt = it->id();
                }
            } else {
                if (codec.pt == it->id() &&
                    codec.clockrate == it->clockrate() &&
                    codec.channels == it->channels()) {
                    if (!foundCandidate) {
                        stream->d->addEncoder(codec);
                        foundCandidate = true;
                    }
                    supported = true;
                    /* Keep our name just to be sure */
                    codec.name = it->name();
                }
            }
        }

        // Remove RTP fb parameters: we currently don't support setting them
        it->setRtpFeedbackProperties({});
        it->setRtpFeedbackIntervals({});

        if (!supported) {
            it = stream->d->payloadTypes.erase(it);
        } else {
            ++it;
        }
    }

    if (stream->d->payloadTypes.empty()) {
        q->warning(u"Remote party %1 did not provide any known %2 payloads for call %3"_s.arg(jid, stream->media(), sid));
        return false;
    }

    return true;
}

bool QXmppCallPrivate::handleTransport(QXmppCallStream *stream, const QXmppJingleIq::Content &content)
{
    if (stream->d->useDtls && !content.transportFingerprint().isEmpty()) {
        if (content.transportFingerprintHash() != u"sha-256") {
            q->warning(u"Unsupported hashing algorithm for DTLS fingerprint: %1."_s.arg(content.transportFingerprintHash()));
            return false;
        }
        stream->d->expectedPeerCertificateDigest = content.transportFingerprint();

        // active/passive part negotiation
        const auto setup = content.transportFingerprintSetup();
        if (setup == u"actpass") {
            stream->d->dtlsPeerSetup = Actpass;
        } else if (setup == u"active") {
            stream->d->dtlsPeerSetup = Active;
        } else if (setup == u"passive") {
            stream->d->dtlsPeerSetup = Passive;
        } else {
            // invalid setup attribute
            return false;
        }

        q->debug(u"Decided to be DTLS %1"_s.arg(stream->d->isDtlsClient() ? u"client (active)" : u"server (passive)"));
        if (stream->d->isDtlsClient()) {
            stream->d->enableDtlsClientMode();
        }
    }

    stream->d->connection->setRemoteUser(content.transportUser());
    stream->d->connection->setRemotePassword(content.transportPassword());
    const auto candidates = content.transportCandidates();
    for (const auto &candidate : candidates) {
        stream->d->connection->addRemoteCandidate(candidate);
    }

    // perform ICE negotiation
    if (!content.transportCandidates().isEmpty()) {
        stream->d->connection->connectToHost();
    }
    return true;
}

std::variant<QXmppIq, QXmppStanza::Error> QXmppCallPrivate::handleRequest(QXmppJingleIq &&iq)
{
    using Error = QXmppStanza::Error;

    Q_ASSERT(manager);  // we are called only from the manager
    const auto contents = iq.contents();

    switch (iq.action()) {
    case QXmppJingleIq::SessionAccept: {
        if (direction == QXmppCall::IncomingDirection) {
            return Error { Error::Cancel, Error::BadRequest, u"'session-accept' for outgoing call"_s };
        }

        for (const auto &content : contents) {
            // check content description and transport
            auto stream = find(streams, content.name(), &QXmppCallStream::name);
            if (!stream ||
                !handleDescription(*stream, content) ||
                !handleTransport(*stream, content)) {

                error = { u"Remote formats or transport unsupported"_s, {} };
                terminate({ QXmppJingleReason::FailedApplication, u"Formats or transport not supported."_s, {} }, true);
                return {};
            }
        }

        // check if all contents have been accepted
        for (const auto *stream : std::as_const(streams)) {
            if (!find(iq.contents(), stream->name(), &QXmppJingleIq::Content::name)) {
                error = { u"Remote did not include all contents in session-accept."_s, {} };
                terminate({ QXmppJingleReason::FailedApplication, u"One or more contents are missing in session-accept."_s, {} }, true);
                return {};
            }
        }

        // check for call establishment
        setState(QXmppCall::ActiveState);
        break;
    }
    case QXmppJingleIq::SessionInfo: {
        // notify user
        if (auto sessionState = iq.rtpSessionState()) {
            if (std::holds_alternative<QXmppJingleIq::RtpSessionStateRinging>(*sessionState)) {
                later(q, [this] {
                    Q_EMIT q->ringing();
                });
            }
        }
        break;
    }
    case QXmppJingleIq::SessionTerminate: {
        // terminate
        q->info(u"Remote party %1 terminated call %2"_s.arg(iq.from(), iq.sid()));
        if (auto reason = iq.actionReason()) {
            if (reason->type() != QXmppJingleReason::None && reason->type() != QXmppJingleReason::Success) {
                // error occurred
                error = { u"Remote terminated call."_s, reason.value() };
            }
        }
        q->terminated();
        break;
    }
    case QXmppJingleIq::ContentAccept: {
        // TODO: check we are creator of the stream; assure session accepted
        for (const auto &content : contents) {
            // check content description and transport
            auto stream = find(streams, content.name(), &QXmppCallStream::name);
            if (!stream ||
                !handleDescription(*stream, content) ||
                !handleTransport(*stream, content)) {

                // FIXME: what action?
                return {};
            }
        }
        break;
    }
    case QXmppJingleIq::ContentAdd: {
        // TODO: assure session accepted
        // check media stream does not exist yet

        for (const auto &content : contents) {
            if (contains(streams, content.name(), &QXmppCallStream::name)) {
                return Error { Error::Cancel, Error::Conflict, u"Media stream '%1' already exists."_s.arg(content.name()) };
            }
        }

        for (const auto &content : contents) {
            // create media stream
            auto *stream = createStream(content.descriptionMedia(), content.creator(), content.name());
            if (!stream) {
                // reject content
                later(this, [this, name = content.name()]() {
                    QXmppJingleIq::Content content;
                    content.setName(name);

                    auto iq = createIq(QXmppJingleIq::ContentReject);
                    iq.setContents({ std::move(content) });
                    iq.setActionReason(QXmppJingleReason { QXmppJingleReason::FailedApplication, {}, {} });
                    manager->client()->sendIq(std::move(iq));
                });
                continue;
            }

            // check content description
            if (!handleDescription(stream, content) ||
                !handleTransport(stream, content)) {

                // reject content
                later(this, [this, name = content.name()]() {
                    QXmppJingleIq::Content content;
                    content.setName(name);

                    auto iq = createIq(QXmppJingleIq::ContentReject);
                    iq.setContents({ std::move(content) });
                    iq.setActionReason(QXmppJingleReason { QXmppJingleReason::FailedApplication, {}, {} });
                    manager->client()->sendIq(std::move(iq));
                });

                streams.removeAll(stream);
                delete stream;
                continue;
            }

            // accept content
            later(this, [this, stream] {
                Q_ASSERT(manager);
                auto iq = createIq(QXmppJingleIq::ContentAccept);
                iq.addContent(localContent(stream));
                manager->client()->sendIq(std::move(iq));
            });
        }
        break;
    }
    case QXmppJingleIq::TransportInfo: {
        // TODO: assure session accepted
        // check content transport
        for (const auto &content : contents) {
            auto stream = find(streams, content.name(), &QXmppCallStream::name);
            if (!stream ||
                !handleTransport(*stream, content)) {
                // FIXME: what action?
                return {};
            }
        }
        break;
    }
    default:
        return Error { Error::Cancel, Error::UnexpectedRequest, u"Unexpected jingle action."_s };
    }

    // send acknowledgement
    return {};
}

QXmppCallStream *QXmppCallPrivate::createStream(const QString &media, const QString &creator, const QString &name)
{
    Q_ASSERT(manager);

    if (media != AUDIO_MEDIA && media != VIDEO_MEDIA) {
        q->warning(u"Unsupported media type %1"_s.arg(media));
        return nullptr;
    }

    if (!isFormatSupported(u"rtpbin"_s)) {
        q->warning(u"The rtpbin GStreamer plugin is missing. Calls are not possible."_s);
        return nullptr;
    }

    auto *stream = new QXmppCallStream(pipeline, rtpBin, media, creator, name, ++nextId, useDtls, q);

    // Fill local payload payload types
    stream->d->payloadTypes = transform<QList<QXmppJinglePayloadType>>(media == AUDIO_MEDIA ? audioCodecs : videoCodecs, [](const auto &codec) {
        QXmppJinglePayloadType payloadType;
        payloadType.setId(codec.pt);
        payloadType.setName(codec.name);
        payloadType.setChannels(codec.channels);
        payloadType.setClockrate(codec.clockrate);
        return payloadType;
    });

    // ICE connection
    stream->d->connection->setIceControlling(direction == QXmppCall::OutgoingDirection);
    stream->d->connection->setStunServers(manager->d->stunServers());
    if (auto turnServer = manager->d->turnServer()) {
        auto turnServerHost = turnServer->host.toString();
        q->debug(u"Call: Using TURN server: " + turnServerHost + u"/" + QString::number(turnServer->port));
        stream->d->connection->setTurnServer(*turnServer);
    }
    stream->d->connection->bind(QXmppIceComponent::discoverAddresses());

    // connect signals
    QObject::connect(stream->d->connection, &QXmppIceConnection::localCandidatesChanged,
                     q, [this, stream]() { q->onLocalCandidatesChanged(stream); });

    QObject::connect(stream->d->connection, &QXmppIceConnection::disconnected, q, [this]() {
        terminate({ QXmppJingleReason::FailedTransport, u"ICE connection could not be established."_s, {} });
    });

    connect(stream->d, &QXmppCallStreamPrivate::peerCertificateReceived, this, [this, stream](bool fingerprintMatches) {
        if (!fingerprintMatches) {
            Q_ASSERT(manager);
            auto reason = QXmppJingleReason { QXmppJingleReason::SecurityError, u"DTLS certificate fingerprint mismatch"_s, {} };

            if (streams.size() > 1 && isOwn(stream)) {
                q->warning(u"DTLS handshake returned unexpected certificate fingerprint."_s);
                auto iq = createIq(QXmppJingleIq::ContentRemove);
                iq.setContents({ localContent(stream) });
                iq.setActionReason(reason);
                manager->client()->sendIq(std::move(iq));

                streams.removeAll(stream);
                stream->deleteLater();
            } else {
                q->warning(u"DTLS handshake returned unexpected certificate fingerprint. Terminating call."_s);
                error = { u"DTLS certificate mismatch"_s, {} };
                terminate(std::move(reason));
            }
        } else {
            q->debug(u"DTLS handshake returned certificate with expected fingerprint."_s);
        }
    });

    streams << stream;
    Q_EMIT q->streamCreated(stream);

    return stream;
}

QXmppJingleIq::Content QXmppCallPrivate::localContent(QXmppCallStream *stream) const
{
    QXmppJingleIq::Content content;
    content.setCreator(stream->creator());
    content.setName(stream->name());
    content.setSenders(u"both"_s);

    // description
    content.setDescriptionMedia(stream->media());
    content.setDescriptionSsrc(stream->d->localSsrc);
    content.setPayloadTypes(stream->d->payloadTypes);

    // transport
    content.setTransportUser(stream->d->connection->localUser());
    content.setTransportPassword(stream->d->connection->localPassword());
    content.setTransportCandidates(stream->d->connection->localCandidates());

    // encryption
    if (useDtls) {
        Q_ASSERT(!stream->d->ownCertificateDigest.isEmpty());
        content.setTransportFingerprint(stream->d->ownCertificateDigest);
        content.setTransportFingerprintHash(u"sha-256"_s);

        // choose whether we are DTLS client or server
        if (stream->d->dtlsPeerSetup.has_value()) {
            content.setTransportFingerprintSetup(stream->d->isDtlsClient() ? u"active"_s : u"passive"_s);
        } else {
            // let other end decide
            content.setTransportFingerprintSetup(u"actpass"_s);
        }
    }

    return content;
}

QXmppJingleIq QXmppCallPrivate::createIq(QXmppJingleIq::Action action) const
{
    Q_ASSERT(manager);

    QXmppJingleIq iq;
    iq.setFrom(manager->client()->configuration().jid());
    iq.setTo(jid);
    iq.setType(QXmppIq::Set);
    iq.setAction(action);
    iq.setSid(sid);
    return iq;
}

void QXmppCallPrivate::sendInvite()
{
    Q_ASSERT(manager);

    auto iq = createIq(QXmppJingleIq::SessionInitiate);
    iq.setInitiator(manager->client()->configuration().jid());
    iq.addContent(localContent(q->audioStream()));
    if (auto video = q->videoStream()) {
        iq.addContent(localContent(video));
    }
    manager->client()->send(std::move(iq));
}

void QXmppCallPrivate::setState(QXmppCall::State newState)
{
    if (state != newState) {
        state = newState;
        Q_EMIT q->stateChanged(state);

        if (state == QXmppCall::ActiveState) {
            Q_EMIT q->connected();
        } else if (state == QXmppCall::FinishedState) {
            Q_EMIT q->finished();
        }
    }
}

///
/// Request graceful call termination
///
void QXmppCallPrivate::terminate(QXmppJingleReason reason, bool delay)
{
    q->debug(u"Call(sid=%1): Terminating: %2"_s.arg(sid, reason.text()));

    if (state == QXmppCall::DisconnectingState ||
        state == QXmppCall::FinishedState) {
        return;
    }

    // hangup call
    auto iq = createIq(QXmppJingleIq::SessionTerminate);
    iq.setActionReason(std::move(reason));

    setState(QXmppCall::DisconnectingState);

    if (delay) {
        later(this, [this, iq = std::move(iq)]() mutable {
            Q_ASSERT(manager);
            manager->client()->sendIq(std::move(iq)).then(q, [this](auto result) {
                // terminate on both success or error
                q->terminated();
            });
        });
    } else {
        manager->client()->sendIq(std::move(iq)).then(q, [this](auto result) {
            // terminate on both success or error
            q->terminated();
        });
    }

    // schedule forceful termination in 5s
    QTimer::singleShot(5s, q, &QXmppCall::terminated);
}

bool QXmppCallPrivate::isOwn(QXmppCallStream *stream) const
{
    bool outgoingCall = direction == QXmppCall::OutgoingDirection;
    bool initiatorsStream = stream->d->creator == u"initiator";

    return outgoingCall && initiatorsStream || !outgoingCall && !initiatorsStream;
}

///
/// \class QXmppCall
///
/// The QXmppCall class represents a Voice-Over-IP call to a remote party.
///
/// \note THIS API IS NOT FINALIZED YET
///

QXmppCall::QXmppCall(const QString &jid, const QString &sid, Direction direction, QXmppCallManager *manager)
    : QXmppCall(jid, sid, direction, ConnectingState, {}, manager)
{
}

QXmppCall::QXmppCall(const QString &jid, const QString &sid, Direction direction, State state, QXmppError &&error, QXmppCallManager *manager)
    : QXmppLoggable(nullptr),
      d(std::make_unique<QXmppCallPrivate>(jid, sid, direction, manager, state, std::move(error), this))
{
    // relay logging (we dont want a direct parent because of our ownership model)
    connect(this, &QXmppLoggable::logMessage, manager, &QXmppLoggable::logMessage);
}

QXmppCall::~QXmppCall() = default;

///
/// Call this method if you wish to accept an incoming call.
///
/// \sa decline()
///
void QXmppCall::accept()
{
    if (d->direction == IncomingDirection && d->state == ConnectingState) {
        Q_ASSERT(d->manager);

        // accept incoming call
        auto iq = d->createIq(QXmppJingleIq::SessionAccept);
        iq.setResponder(d->manager->client()->configuration().jid());
        for (auto *stream : std::as_const(d->streams)) {
            iq.addContent(d->localContent(stream));
        }
        d->manager->client()->sendIq(std::move(iq));

        // check for call establishment
        d->setState(QXmppCall::ActiveState);
    }
}

///
/// Rejects the call.
///
/// This will terminate the call with reason *decline*.
///
/// \sa accept()
///
void QXmppCall::decline()
{
    d->terminate({ QXmppJingleReason::Decline, {}, {} });
}

///
/// Returns the GStreamer pipeline.
///
/// \since QXmpp 1.3
///
GstElement *QXmppCall::pipeline() const
{
    return d->pipeline;
}

///
/// Returns the RTP stream for the audio data.
///
/// \since QXmpp 1.3
///
QXmppCallStream *QXmppCall::audioStream() const
{
    return find(d->streams, AUDIO_MEDIA, &QXmppCallStream::media).value_or(nullptr);
}

///
/// Returns the RTP stream for the video data.
///
/// \since QXmpp 1.3
///
QXmppCallStream *QXmppCall::videoStream() const
{
    return find(d->streams, VIDEO_MEDIA, &QXmppCallStream::media).value_or(nullptr);
}

void QXmppCall::terminated()
{
    // close streams
    for (auto stream : std::as_const(d->streams)) {
        stream->d->connection->close();
    }

    // update state
    d->setState(QXmppCall::FinishedState);
}

///
/// Returns the call's direction.
///
QXmppCall::Direction QXmppCall::direction() const
{
    return d->direction;
}

///
/// Hangs up the call.
///
/// Terminates with the reason *success*.
///
void QXmppCall::hangUp()
{
    d->terminate({ QXmppJingleReason::Success, {}, {} });
}

///
/// Sends a transport-info to inform the remote party of new local candidates.
///
void QXmppCall::onLocalCandidatesChanged(QXmppCallStream *stream)
{
    auto iq = d->createIq(QXmppJingleIq::TransportInfo);
    iq.addContent(d->localContent(stream));
    d->manager->client()->sendIq(std::move(iq));
}

///
/// Returns the remote party's JID.
///
QString QXmppCall::jid() const
{
    return d->jid;
}

///
/// Returns the call's session identifier.
///
QString QXmppCall::sid() const
{
    return d->sid;
}

///
/// Returns the call's state.
///
/// \sa stateChanged()
///
QXmppCall::State QXmppCall::state() const
{
    return d->state;
}

///
/// Returns the error of the call if any occurred.
///
/// \since QXmpp 1.14
///
std::optional<QXmppError> QXmppCall::error() const
{
    if (!d->error.description.isEmpty()) {
        return d->error;
    }
    return {};
}

///
/// Starts sending video to the remote party.
///
void QXmppCall::addVideo()
{
    if (d->state != QXmppCall::ActiveState) {
        warning(u"Cannot add video, call is not active"_s);
        return;
    }

    if (!d->videoSupported) {
        warning(u"Cannot add video, remote does not support video."_s);
        return;
    }

    if (contains(d->streams, VIDEO_MEDIA, &QXmppCallStream::media)) {
        warning(u"Video stream already exists."_s);
        return;
    }

    // create video stream
    QString creator = (d->direction == QXmppCall::OutgoingDirection) ? u"initiator"_s : u"responder"_s;
    auto *stream = d->createStream(VIDEO_MEDIA.toString(), creator, u"webcam"_s);

    // build request
    auto iq = d->createIq(QXmppJingleIq::ContentAdd);
    iq.addContent(d->localContent(stream));
    d->manager->client()->sendIq(std::move(iq));
}

///
/// Returns if the call is encrypted
///
/// \since QXmpp 1.11
///
bool QXmppCall::isEncrypted() const
{
    return d->useDtls;
}

///
/// Returns whether the remote also supports video calls.
///
/// \since QXmpp 1.14
///
bool QXmppCall::videoSupported() const
{
    return d->videoSupported;
}
