#include <QCoreApplication>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusError>
#include <QDBusInterface>
#include <QDBusMetaType>
#include <QDBusObjectPath>
#include <QDBusReply>
#include <QDBusUnixFileDescriptor>
#include <QDBusVariant>
#include <QFile>
#include <QImage>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QTextStream>
#include <QTimer>
#include <QVariantMap>

#include <gst/app/gstappsink.h>
#include <gst/gst.h>
#include <gst/video/video.h>

#include <unistd.h>

static const char *PORTAL_SERVICE = "org.freedesktop.portal.Desktop";
static const char *PORTAL_PATH = "/org/freedesktop/portal/desktop";
static const char *SCREENCAST_IFACE = "org.freedesktop.portal.ScreenCast";
static const uint SOURCE_TYPE_MONITOR = 1;
static const uint SOURCE_TYPE_WINDOW = 2;
static const uint CURSOR_MODE_HIDDEN = 1;

struct PipeWireStream
{
    uint nodeId = 0;
    QVariantMap properties;
};

Q_DECLARE_METATYPE(PipeWireStream)
Q_DECLARE_METATYPE(QList<PipeWireStream>)

const QDBusArgument &operator>>(const QDBusArgument &argument, PipeWireStream &stream)
{
    argument.beginStructure();
    argument >> stream.nodeId >> stream.properties;
    argument.endStructure();
    return argument;
}

QDBusArgument &operator<<(QDBusArgument &argument, const PipeWireStream &stream)
{
    argument.beginStructure();
    argument << stream.nodeId << stream.properties;
    argument.endStructure();
    return argument;
}

class RequestWatcher : public QObject
{
    Q_OBJECT

public:
    uint response = 2;
    QVariantMap results;
    bool done = false;
    QString error;

public slots:
    void Response(uint responseCode, const QVariantMap &responseResults)
    {
        response = responseCode;
        results = responseResults;
        done = true;
        QCoreApplication::quit();
    }
};

QDBusConnection sessionBus()
{
    QDBusConnection bus = QDBusConnection::sessionBus();
    if (bus.isConnected()) {
        return bus;
    }

    QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR");
    if (runtimeDir.isEmpty()) {
        runtimeDir = QStringLiteral("/run/user/%1").arg(getuid());
    }

    return QDBusConnection::connectToBus(
        QStringLiteral("unix:path=%1/bus").arg(runtimeDir),
        QStringLiteral("reforger-portal-session"));
}

QString token()
{
    return QStringLiteral("reforger_%1_%2")
        .arg(QCoreApplication::applicationPid())
        .arg(QString::number(QRandomGenerator::global()->generate(), 16));
}

bool waitForRequest(QCoreApplication &app, QDBusConnection &bus, const QDBusObjectPath &handle, int timeoutMs, RequestWatcher &watcher, QTextStream &err)
{
    const bool connected = bus.connect(
        QString::fromLatin1(PORTAL_SERVICE),
        handle.path(),
        QStringLiteral("org.freedesktop.portal.Request"),
        QStringLiteral("Response"),
        &watcher,
        SLOT(Response(uint,QVariantMap)));
    if (!connected) {
        err << "failed to listen for portal request response on " << handle.path() << ": "
            << bus.lastError().message() << "\n";
        return false;
    }

    QTimer::singleShot(timeoutMs, &app, [&watcher]() {
        if (!watcher.done) {
            watcher.error = QStringLiteral("timed out waiting for portal response");
            watcher.done = true;
            QCoreApplication::quit();
        }
    });
    app.exec();
    bus.disconnect(
        QString::fromLatin1(PORTAL_SERVICE),
        handle.path(),
        QStringLiteral("org.freedesktop.portal.Request"),
        QStringLiteral("Response"),
        &watcher,
        SLOT(Response(uint,QVariantMap)));

    if (!watcher.error.isEmpty()) {
        err << watcher.error << "\n";
        return false;
    }
    if (watcher.response == 1) {
        err << "portal request was cancelled\n";
        return false;
    }
    if (watcher.response != 0) {
        err << "portal request failed with response code " << watcher.response << "\n";
        return false;
    }
    return true;
}

QVariantMap callRequest(
    QCoreApplication &app,
    QDBusConnection &bus,
    QDBusInterface &portal,
    const QString &method,
    const QList<QVariant> &args,
    int timeoutMs,
    QTextStream &err,
    bool *ok)
{
    *ok = false;
    QDBusMessage reply = portal.callWithArgumentList(QDBus::Block, method, args);
    if (reply.type() == QDBusMessage::ErrorMessage) {
        err << method << " failed: " << reply.errorName() << ": " << reply.errorMessage() << "\n";
        return {};
    }
    if (reply.arguments().isEmpty()) {
        err << method << " returned no request handle\n";
        return {};
    }

    QDBusObjectPath handle = qdbus_cast<QDBusObjectPath>(reply.arguments().at(0));
    if (handle.path().isEmpty()) {
        err << method << " returned an invalid request handle\n";
        return {};
    }

    RequestWatcher watcher;
    if (!waitForRequest(app, bus, handle, timeoutMs, watcher, err)) {
        return {};
    }
    *ok = true;
    return watcher.results;
}

uint portalUIntProperty(QDBusConnection &bus, const QString &name)
{
    QDBusInterface props(
        QString::fromLatin1(PORTAL_SERVICE),
        QString::fromLatin1(PORTAL_PATH),
        QStringLiteral("org.freedesktop.DBus.Properties"),
        bus);
    QDBusReply<QDBusVariant> reply = props.call(QStringLiteral("Get"), QString::fromLatin1(SCREENCAST_IFACE), name);
    if (!reply.isValid()) {
        return 0;
    }
    return reply.value().variant().toUInt();
}

QList<PipeWireStream> parseStreams(const QVariant &value)
{
    QList<PipeWireStream> streams = qdbus_cast<QList<PipeWireStream>>(value);
    if (!streams.isEmpty()) {
        return streams;
    }

    if (value.canConvert<QDBusArgument>()) {
        return qdbus_cast<QList<PipeWireStream>>(value.value<QDBusArgument>());
    }

    return {};
}

QString variantString(const QVariantMap &map, const QString &key)
{
    const QVariant value = map.value(key);
    if (value.canConvert<QDBusObjectPath>()) {
        return value.value<QDBusObjectPath>().path();
    }
    return value.toString();
}

void closePortalSession(QDBusConnection &bus, const QString &sessionHandle)
{
    if (sessionHandle.isEmpty()) {
        return;
    }

    QDBusInterface session(
        QString::fromLatin1(PORTAL_SERVICE),
        sessionHandle,
        QStringLiteral("org.freedesktop.portal.Session"),
        bus);
    if (session.isValid()) {
        session.call(QStringLiteral("Close"));
    }
}

int writePipeWireFrame(int fd, uint nodeId, const QString &outputPath, int timeoutMs, QTextStream &err)
{
    GstElement *pipeline = gst_pipeline_new("reforger-portal-capture");
    GstElement *source = gst_element_factory_make("pipewiresrc", "source");
    GstElement *convert = gst_element_factory_make("videoconvert", "convert");
    GstElement *filter = gst_element_factory_make("capsfilter", "filter");
    GstElement *sink = gst_element_factory_make("appsink", "sink");

    if (!pipeline || !source || !convert || !filter || !sink) {
        err << "failed to create GStreamer PipeWire capture pipeline\n";
        if (pipeline) {
            gst_object_unref(pipeline);
        }
        return 1;
    }

    const QByteArray node = QByteArray::number(nodeId);
    g_object_set(source, "fd", fd, "path", node.constData(), "num-buffers", 1, nullptr);

    GstCaps *caps = gst_caps_new_simple("video/x-raw", "format", G_TYPE_STRING, "BGRA", nullptr);
    g_object_set(filter, "caps", caps, nullptr);
    gst_caps_unref(caps);

    g_object_set(sink, "emit-signals", FALSE, "sync", FALSE, "max-buffers", 1, "drop", TRUE, nullptr);

    gst_bin_add_many(GST_BIN(pipeline), source, convert, filter, sink, nullptr);
    if (!gst_element_link_many(source, convert, filter, sink, nullptr)) {
        err << "failed to link GStreamer PipeWire capture pipeline\n";
        gst_object_unref(pipeline);
        return 1;
    }

    if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
        err << "failed to start GStreamer PipeWire capture pipeline\n";
        gst_object_unref(pipeline);
        return 1;
    }

    GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(sink), timeoutMs * GST_MSECOND);
    if (!sample) {
        GstBus *bus = gst_element_get_bus(pipeline);
        GstMessage *message = gst_bus_pop_filtered(bus, static_cast<GstMessageType>(GST_MESSAGE_ERROR | GST_MESSAGE_EOS));
        if (message && GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
            GError *error = nullptr;
            gchar *debug = nullptr;
            gst_message_parse_error(message, &error, &debug);
            err << "GStreamer PipeWire capture failed: " << (error ? error->message : "unknown error") << "\n";
            if (debug) {
                err << debug << "\n";
            }
            if (error) {
                g_error_free(error);
            }
            g_free(debug);
        } else {
            err << "timed out waiting for a PipeWire video frame\n";
        }
        if (message) {
            gst_message_unref(message);
        }
        gst_object_unref(bus);
        gst_element_set_state(pipeline, GST_STATE_NULL);
        gst_object_unref(pipeline);
        return 1;
    }

    GstCaps *sampleCaps = gst_sample_get_caps(sample);
    GstVideoInfo info;
    if (!sampleCaps || !gst_video_info_from_caps(&info, sampleCaps)) {
        err << "failed to read video frame format from PipeWire sample\n";
        gst_sample_unref(sample);
        gst_element_set_state(pipeline, GST_STATE_NULL);
        gst_object_unref(pipeline);
        return 1;
    }

    GstBuffer *buffer = gst_sample_get_buffer(sample);
    GstMapInfo map;
    if (!buffer || !gst_buffer_map(buffer, &map, GST_MAP_READ)) {
        err << "failed to map PipeWire video frame\n";
        gst_sample_unref(sample);
        gst_element_set_state(pipeline, GST_STATE_NULL);
        gst_object_unref(pipeline);
        return 1;
    }

    const int width = GST_VIDEO_INFO_WIDTH(&info);
    const int height = GST_VIDEO_INFO_HEIGHT(&info);
    const int stride = GST_VIDEO_INFO_PLANE_STRIDE(&info, 0);
    QImage image(map.data, width, height, stride, QImage::Format_ARGB32);
    const bool saved = image.copy().save(outputPath, "PNG");

    gst_buffer_unmap(buffer, &map);
    gst_sample_unref(sample);
    gst_element_set_state(pipeline, GST_STATE_NULL);
    gst_object_unref(pipeline);

    if (!saved) {
        err << "failed to write PNG output: " << outputPath << "\n";
        return 1;
    }

    return 0;
}

int captureWindow(QCoreApplication &app, const QString &outputPath, const QString &restoreToken, bool persist, int timeoutMs, QTextStream &out, QTextStream &err)
{
    QDBusConnection bus = sessionBus();
    if (!bus.isConnected()) {
        err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n";
        return 1;
    }

    const uint sourceTypes = portalUIntProperty(bus, QStringLiteral("AvailableSourceTypes"));
    if (!(sourceTypes & SOURCE_TYPE_WINDOW)) {
        err << "desktop portal does not advertise window capture support";
        if (sourceTypes) {
            err << " (AvailableSourceTypes=" << sourceTypes << ")";
        }
        err << "\n";
        return 1;
    }

    QDBusInterface portal(
        QString::fromLatin1(PORTAL_SERVICE),
        QString::fromLatin1(PORTAL_PATH),
        QString::fromLatin1(SCREENCAST_IFACE),
        bus);
    if (!portal.isValid()) {
        err << "failed to connect to desktop ScreenCast portal: " << portal.lastError().message() << "\n";
        return 1;
    }

    QVariantMap createOptions;
    createOptions.insert(QStringLiteral("handle_token"), token());
    createOptions.insert(QStringLiteral("session_handle_token"), token());
    bool ok = false;
    QVariantMap createResults = callRequest(app, bus, portal, QStringLiteral("CreateSession"), {createOptions}, timeoutMs, err, &ok);
    if (!ok) {
        return 1;
    }
    const QString sessionHandle = variantString(createResults, QStringLiteral("session_handle"));
    if (sessionHandle.isEmpty()) {
        err << "portal CreateSession did not return a session handle\n";
        return 1;
    }

    QVariantMap selectOptions;
    selectOptions.insert(QStringLiteral("handle_token"), token());
    selectOptions.insert(QStringLiteral("types"), SOURCE_TYPE_WINDOW);
    selectOptions.insert(QStringLiteral("multiple"), false);
    selectOptions.insert(QStringLiteral("cursor_mode"), CURSOR_MODE_HIDDEN);
    if (!restoreToken.isEmpty()) {
        selectOptions.insert(QStringLiteral("restore_token"), restoreToken);
    }
    if (persist) {
        selectOptions.insert(QStringLiteral("persist_mode"), 2u);
    }
    callRequest(app, bus, portal, QStringLiteral("SelectSources"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), selectOptions}, timeoutMs, err, &ok);
    if (!ok) {
        closePortalSession(bus, sessionHandle);
        return 1;
    }

    QVariantMap startOptions;
    startOptions.insert(QStringLiteral("handle_token"), token());
    QVariantMap startResults = callRequest(app, bus, portal, QStringLiteral("Start"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), QString(), startOptions}, timeoutMs, err, &ok);
    if (!ok) {
        closePortalSession(bus, sessionHandle);
        return 1;
    }
    const QList<PipeWireStream> streams = parseStreams(startResults.value(QStringLiteral("streams")));
    if (streams.isEmpty()) {
        err << "portal Start did not return any PipeWire streams\n";
        closePortalSession(bus, sessionHandle);
        return 1;
    }

    const PipeWireStream stream = streams.first();
    if (stream.nodeId == 0) {
        err << "portal returned an invalid PipeWire node id\n";
        closePortalSession(bus, sessionHandle);
        return 1;
    }

    QDBusReply<QDBusUnixFileDescriptor> fdReply = portal.call(
        QStringLiteral("OpenPipeWireRemote"),
        QVariant::fromValue(QDBusObjectPath(sessionHandle)),
        QVariantMap());
    if (!fdReply.isValid()) {
        err << "OpenPipeWireRemote failed: " << fdReply.error().name() << ": " << fdReply.error().message() << "\n";
        closePortalSession(bus, sessionHandle);
        return 1;
    }

    const QDBusUnixFileDescriptor remoteFd = fdReply.value();
    if (!remoteFd.isValid()) {
        err << "OpenPipeWireRemote returned an invalid file descriptor\n";
        closePortalSession(bus, sessionHandle);
        return 1;
    }

    const int status = writePipeWireFrame(remoteFd.fileDescriptor(), stream.nodeId, outputPath, timeoutMs, err);

    closePortalSession(bus, sessionHandle);

    if (status != 0) {
        return status;
    }

    QJsonObject info;
    info.insert(QStringLiteral("node_id"), static_cast<int>(stream.nodeId));
    info.insert(QStringLiteral("session_handle"), sessionHandle);
    const QString newRestoreToken = variantString(startResults, QStringLiteral("restore_token"));
    if (!newRestoreToken.isEmpty()) {
        info.insert(QStringLiteral("restore_token"), newRestoreToken);
    }
    QJsonArray streamArray;
    for (const PipeWireStream &pipeWireStream : streams) {
        QJsonObject streamObject;
        streamObject.insert(QStringLiteral("node_id"), static_cast<int>(pipeWireStream.nodeId));
        QJsonObject properties;
        for (auto it = pipeWireStream.properties.constBegin(); it != pipeWireStream.properties.constEnd(); ++it) {
            properties.insert(it.key(), QJsonValue::fromVariant(it.value()));
        }
        streamObject.insert(QStringLiteral("properties"), properties);
        streamArray.append(streamObject);
    }
    info.insert(QStringLiteral("streams"), streamArray);
    out << QJsonDocument(info).toJson(QJsonDocument::Compact) << "\n";

    return 0;
}

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);
    gst_init(&argc, &argv);
    qDBusRegisterMetaType<PipeWireStream>();
    qDBusRegisterMetaType<QList<PipeWireStream>>();

    QTextStream out(stdout);
    QTextStream err(stderr);
    const QStringList args = QCoreApplication::arguments();

    QString outputPath;
    QString restoreToken;
    bool persist = false;
    int timeoutMs = 60000;

    for (int i = 1; i < args.size(); ++i) {
        const QString arg = args.at(i);
        if (arg == QLatin1String("--window") && i + 1 < args.size()) {
            outputPath = args.at(++i);
        } else if (arg == QLatin1String("--restore-token") && i + 1 < args.size()) {
            restoreToken = args.at(++i);
        } else if (arg == QLatin1String("--persist")) {
            persist = true;
        } else if (arg == QLatin1String("--timeout") && i + 1 < args.size()) {
            bool ok = false;
            const double seconds = args.at(++i).toDouble(&ok);
            if (!ok || seconds <= 0) {
                err << "invalid --timeout value\n";
                return 64;
            }
            timeoutMs = qRound(seconds * 1000.0);
        } else {
            err << "unknown or incomplete argument: " << arg << "\n";
            return 64;
        }
    }

    if (outputPath.isEmpty()) {
        err << "usage: " << args.value(0)
            << " --window OUTPUT.png [--restore-token TOKEN] [--persist] [--timeout SECONDS]\n";
        return 64;
    }

    return captureWindow(app, outputPath, restoreToken, persist, timeoutMs, out, err);
}

#include "portal_capture_frame.moc"
