/*
 * Copyright (C) 2026 UBports Foundation.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 as
 * published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

#include "../../src/MultipartDevice.h"

#include <gtest/gtest.h>
#include <QBuffer>
#include <QCoreApplication>
#include <QRegularExpression>
#include <QSet>

#include <cstring>

using namespace std;

namespace
{

constexpr char const* FILENAME = "foo.txt";
constexpr char const* CONTENT_TYPE = "text/plain";

class FakeLocalSocket : public QIODevice
{
public:
    FakeLocalSocket(QByteArray const& data, qint64 max_per_read)
        : data_(data), max_per_read_(max_per_read)
    {
        open(QIODevice::ReadOnly);
    }

    bool isSequential() const override { return true; }

    qint64 bytesAvailable() const override
    {
        return qMin(qint64(data_.size()) - pos_, max_per_read_)
            + QIODevice::bytesAvailable();
    }

    void deliver(QByteArray const& more)
    {
        data_.append(more);
        Q_EMIT readyRead();
    }

    void finish() { Q_EMIT readChannelFinished(); }

protected:
    qint64 readData(char* out, qint64 maxSize) override
    {
        qint64 n = qMin(qMin(maxSize, max_per_read_),
                        qint64(data_.size()) - pos_);
        if (n <= 0) return 0;
        memcpy(out, data_.constData() + pos_, n);
        pos_ += n;
        return n;
    }

    qint64 writeData(char const*, qint64) override { return -1; }

private:
    QByteArray data_;
    qint64 const max_per_read_;
    qint64 pos_ = 0;
};

// Recover boundary from the header
QByteArray boundary_of(MultipartDevice& device)
{
    QRegularExpression re(QStringLiteral("boundary=\"([^\"]+)\""));
    return re.match(QString::fromUtf8(device.content_type_header()))
        .captured(1).toUtf8();
}

QByteArray expected_body(QByteArray const& payload, QByteArray const& boundary)
{
    return "--" + boundary + "\r\n"
        + "Content-Disposition: form-data; name=\"file\"; filename=\""
        + QByteArray(FILENAME) + "\"\r\n"
        + "Content-Type: " + QByteArray(CONTENT_TYPE) + "\r\n\r\n"
        + payload
        + "\r\n--" + boundary + "--\r\n";
}

// Read all data from the device
QByteArray read_all(MultipartDevice& device, qint64 chunk)
{
    QByteArray out;
    QByteArray buf(chunk, Qt::Uninitialized);
    Q_FOREVER
    {
        qint64 n = device.read(buf.data(), chunk);
        if (n <= 0) break;
        out.append(buf.constData(), n);
    }
    return out;
}

}  // namespace

class MultipartDeviceTests : public ::testing::Test {};

TEST_F(MultipartDeviceTests, framing_matches)
{
    QByteArray const payload = "hello world";
    QBuffer source;
    source.setData(payload);
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    EXPECT_EQ(expected_body(payload, boundary_of(device)),
              read_all(device, 4096));
}

TEST_F(MultipartDeviceTests, total_size_matches_bytes_produced)
{
    QByteArray const payload = "hello world";
    QBuffer source;
    source.setData(payload);
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    qint64 const promised = device.total_size();
    EXPECT_EQ(promised, read_all(device, 4096).size());
}

TEST_F(MultipartDeviceTests, boundary_is_quoted_in_content_type)
{
    QByteArray const payload = "hello world";
    QBuffer source;
    source.setData(payload);
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    QByteArray const header = device.content_type_header();
    EXPECT_TRUE(header.startsWith("multipart/form-data; boundary=\""))
        << header.constData();
    EXPECT_TRUE(header.endsWith("\"")) << header.constData();
    EXPECT_FALSE(boundary_of(device).isEmpty());
}

TEST_F(MultipartDeviceTests, boundary_unique_within_restrictions)
{
    QSet<QByteArray> seen;
    for (int i = 0; i < 64; i++)
    {
        QBuffer source;
        source.open(QIODevice::ReadOnly);
        MultipartDevice device(&source, 0, QString::fromUtf8(FILENAME),
                               QString::fromUtf8(CONTENT_TYPE));
        QByteArray const boundary = boundary_of(device);

        // At most 70 characters.
        EXPECT_LE(boundary.size(), 70) << boundary.constData();
        // Alphanumerics and "-" only
        EXPECT_TRUE(QRegularExpression(QStringLiteral("^[0-9a-zA-Z-]+$"))
                        .match(QString::fromUtf8(boundary)).hasMatch())
            << boundary.constData();

        // Boundary must be difficult to predict, a malicious payload should
        // not be able to guess it, otherwise it could decide to write over
        // arbitrary files.
        EXPECT_FALSE(seen.contains(boundary)) << boundary.constData();
        seen.insert(boundary);
    }
}

TEST_F(MultipartDeviceTests, default_content_type_when_unset)
{
    QByteArray const payload = "abc";
    QBuffer source;
    source.setData(payload);
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME), QString());

    EXPECT_TRUE(read_all(device, 4096)
                    .contains("Content-Type: application/octet-stream\r\n"));
}

TEST_F(MultipartDeviceTests, filename_is_escaped)
{
    QByteArray const payload = "abc";
    QBuffer source;
    source.setData(payload);
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, payload.size(),
                           QStringLiteral("aw\"kw\\ard\r\n.txt"),
                           QString::fromUtf8(CONTENT_TYPE));

    QByteArray const body = read_all(device, 4096);
    EXPECT_TRUE(body.contains("filename=\"aw\\\"kw\\\\ard.txt\""))
        << body.constData();
    EXPECT_FALSE(body.contains("ard\r\n.txt"));
}

TEST_F(MultipartDeviceTests, empty_payload)
{
    QBuffer source;
    source.setData(QByteArray());
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, 0, QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    EXPECT_EQ(expected_body(QByteArray(), boundary_of(device)),
              read_all(device, 4096));
}

TEST_F(MultipartDeviceTests, small_reads_cross_segment_boundaries)
{
    QByteArray const payload = "hello world";
    for (qint64 chunk : {qint64(1), qint64(7), qint64(64)})
    {
        QBuffer source;
        source.setData(payload);
        source.open(QIODevice::ReadOnly);

        MultipartDevice device(&source, payload.size(),
                               QString::fromUtf8(FILENAME),
                               QString::fromUtf8(CONTENT_TYPE));

        EXPECT_EQ(expected_body(payload, boundary_of(device)),
                  read_all(device, chunk))
            << "chunk size " << chunk;
    }
}

TEST_F(MultipartDeviceTests, cursor_inside_payload_after_short_read)
{
    QByteArray const payload = "hello world";
    FakeLocalSocket source(payload, 3);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    EXPECT_EQ(expected_body(payload, boundary_of(device)),
              read_all(device, 4096));
}

TEST_F(MultipartDeviceTests, large_payload_with_short_reads)
{
    QByteArray const payload(200 * 1024, 'x');
    FakeLocalSocket source(payload, 8192);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    EXPECT_EQ(expected_body(payload, boundary_of(device)),
              read_all(device, 64 * 1024));
}

TEST_F(MultipartDeviceTests, payload_shorter_than_promised_fails)
{
    QByteArray const payload = "hello";
    FakeLocalSocket source(payload, 4096);

    MultipartDevice device(&source, payload.size() + 10,
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    QByteArray buf(4096, Qt::Uninitialized);
    EXPECT_GT(device.read(buf.data(), buf.size()), 0);
    EXPECT_EQ(0, device.read(buf.data(), buf.size())); // nothing to read

    source.finish(); // signal that the source is done
    EXPECT_EQ(-1, device.read(buf.data(), buf.size()));
}

TEST_F(MultipartDeviceTests, resumes_after_late_payload)
{
    QByteArray const payload = "hello world";
    FakeLocalSocket source(QByteArray(), 4096);  // open, empty

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    QByteArray out;
    QByteArray buf(4096, Qt::Uninitialized);

    qint64 const n = device.read(buf.data(), buf.size());
    EXPECT_GT(n, 0);  // prologue is available
    out.append(buf.constData(), n);

    EXPECT_EQ(0, device.read(buf.data(), buf.size()));  // wait, not fail

    source.deliver(payload);  // peer writes

    out.append(read_all(device, 4096));
    EXPECT_EQ(expected_body(payload, boundary_of(device)), out);
}

TEST_F(MultipartDeviceTests, bytes_available)
{
    QByteArray const payload = "hello world";
    FakeLocalSocket source(QByteArray(), 4096);

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    // "\r\n--" + boundary + "--\r\n"
    qint64 const epilogue = 8 + boundary_of(device).size();
    qint64 const prologue = device.total_size() - payload.size() - epilogue;

    EXPECT_EQ(prologue, device.bytesAvailable());

    // Producing increases availability
    source.deliver(payload);
    EXPECT_EQ(device.total_size(), device.bytesAvailable());

    // Consuming decreases availability
    QByteArray buf(4096, Qt::Uninitialized);
    EXPECT_EQ(5, device.read(buf.data(), 5));
    EXPECT_EQ(device.total_size() - 5, device.bytesAvailable());

    read_all(device, 4096);
    EXPECT_EQ(0, device.bytesAvailable());
}

TEST_F(MultipartDeviceTests, bytes_available_payload_partly_buffered)
{
    QByteArray const payload(100, 'x');   // 100 bytes
    FakeLocalSocket source(payload, 10);  // 10 bytes at a time

    MultipartDevice device(&source, payload.size(),
                           QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    qint64 const epilogue = 8 + boundary_of(device).size();
    qint64 const prologue = device.total_size() - payload.size() - epilogue;

    EXPECT_EQ(prologue + 10, device.bytesAvailable());
}

TEST_F(MultipartDeviceTests, bytes_available_with_empty_payload)
{
    QBuffer source;
    source.open(QIODevice::ReadOnly);

    MultipartDevice device(&source, 0, QString::fromUtf8(FILENAME),
                           QString::fromUtf8(CONTENT_TYPE));

    EXPECT_EQ(device.total_size(), device.bytesAvailable()); // prologue + epilogue
}

int main(int argc, char** argv)
{
    QCoreApplication app(argc, argv);
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}
