// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only

#include <QtCore/qmimetype.h>
#include <QtCore/qtemporarydir.h>

#include <QtMultimedia/qaudiobufferinput.h>
#include <QtMultimedia/qaudiodevice.h>
#include <QtMultimedia/qmediacapturesession.h>
#include <QtMultimedia/qmediadevices.h>
#include <QtMultimedia/qmediaformat.h>
#include <QtMultimedia/qmediarecorder.h>
#include <QtMultimedia/qwavedecoder.h>

#include <QtMultimediaTestLib/private/audiogenerationutils_p.h>
#include <QtMultimediaTestLib/private/capturesessionfixture_p.h>
#include <QtMultimediaTestLib/private/formatutils_p.h>
#include <QtMultimediaTestLib/private/mediabackendutils_p.h>
#include <QtMultimediaTestLib/private/qintegrationtestbase_p.h>
#include <QtMultimediaTestLib/private/mediainfo_p.h>
#include <QtMultimediaTestLib/private/osdetection_p.h>
#include <QtMultimediaTestLib/private/qcolorutil_p.h>
#include <QtMultimediaTestLib/private/qfileutil_p.h>

#include <QtTest/qtest.h>

#include <chrono>

using namespace std::chrono_literals;

namespace {

bool isSupportedPixelFormat(QVideoFrameFormat::PixelFormat pixelFormat)
{
    // TODO: Enable more pixel formats once support is added
    switch (pixelFormat) {
    case QVideoFrameFormat::Format_AYUV:
    case QVideoFrameFormat::Format_AYUV_Premultiplied:
    case QVideoFrameFormat::Format_YV12:
    case QVideoFrameFormat::Format_IMC1:
    case QVideoFrameFormat::Format_IMC2:
    case QVideoFrameFormat::Format_IMC3:
    case QVideoFrameFormat::Format_IMC4:
    case QVideoFrameFormat::Format_YUV420P10: // TODO: Cpu conversion not implemented, fails in
                                              // CI if RHI is not supported
    case QVideoFrameFormat::Format_Y16: // TODO: Fails on Android
    case QVideoFrameFormat::Format_P010: // TODO: Fails on Android
    case QVideoFrameFormat::Format_P016: // TODO: Fails on Android
    case QVideoFrameFormat::Format_SamplerExternalOES:
    case QVideoFrameFormat::Format_SamplerRect:
        return false;
    default:
        return true;
    }
}

std::set<QMediaFormat::VideoCodec> unsupportedVideoCodecs(QMediaFormat::FileFormat fileFormat)
{
    using VideoCodec = QMediaFormat::VideoCodec;

    std::set<QMediaFormat::VideoCodec> unsupportedCodecs;
    if constexpr (isMacOS) {
        if constexpr (isArm) {
            if (fileFormat == QMediaFormat::FileFormat::WMV)
                unsupportedCodecs.insert(VideoCodec::H264);
            else if (fileFormat == QMediaFormat::FileFormat::AVI)
                unsupportedCodecs.insert(VideoCodec::H264);
            else if (fileFormat == QMediaFormat::MPEG4)
                unsupportedCodecs.insert(VideoCodec::H264);
            else if (fileFormat == QMediaFormat::QuickTime)
                unsupportedCodecs.insert(VideoCodec::H264);
        }
    }

    return unsupportedCodecs;
}

void verifyEncodedFrames(const MediaInfo &info, const std::vector<QColor> &expectedColors)
{
    QCOMPARE_EQ(size_t(info.m_frameCount), expectedColors.size());
    QCOMPARE_EQ(info.m_colors.size(), expectedColors.size());
    for (auto i = 0U; i < expectedColors.size(); ++i)
        QVERIFY2(fuzzyCompare(info.m_colors[i][0], expectedColors.at(i)),
                 qPrintable(QString("Frame %1: expected %2, got %3")
                                    .arg(i)
                                    .arg(expectedColors.at(i).name())
                                    .arg(info.m_colors.at(i).at(0).name())));
}

} // namespace

using namespace Qt::StringLiterals;

class tst_QMediaRecorderBackend : public QIntegrationTestBase
{
    Q_OBJECT

public slots:

    void cleanupTestCase();

private slots:
    void initTestCase()
    {
        if (!initIntegrationTestCase())
            return;
    }

    void record_createsFileWithExpectedExtension_whenRecordingAudio_data();
    void record_createsFileWithExpectedExtension_whenRecordingAudio();

    void record_writesVideo_whenInputFrameShrinksOverTime();

    void record_writesVideo_whenInputFrameGrowsOverTime();

    void record_stopsRecording_whenInputsReportedEndOfStream_data();
    void record_stopsRecording_whenInputsReportedEndOfStream();

    void record_writesVideo_withoutTransforms_whenPresentationTransformsPresent_data();
    void record_writesVideo_withoutTransforms_whenPresentationTransformsPresent();

    void record_writesVideo_withCorrectColors_data();
    void record_writesVideo_withCorrectColors();

    void actualLocation_returnsNonEmptyLocation_whenRecorderEntersRecordingState();

    void record_writesToOutputDevice_whenWritableOutputDeviceAndLocationAreSet();

    void record_writesToOutputLocation_whenNotWritableOutputDeviceAndLocationAreSet();

    void record_writesVideo_withAllSupportedVideoFormats_data();
    void record_writesVideo_withAllSupportedVideoFormats();

    void record_writesAudio_withAllSupportedAudioFormats_data();
    void record_writesAudio_withAllSupportedAudioFormats();

    void record_emits_mediaformatChanged_whenFormatChanged();

    void stop_stopsRecording_whenInvokedUponRecordingStart();

    void record_reflectsAudioEncoderSetting();

    void record_adjustsFrameRate_whenSourceFrameRateDifferentFromEncoderFrameRate_data();
    void record_adjustsFrameRate_whenSourceFrameRateDifferentFromEncoderFrameRate();

    void record_writesLastFrame_whenAdapterHadBufferedItBeforeEndOfStream();

    void record_encodesFrames_whenSourceTimestampsAreIrregular_data();
    void record_encodesFrames_whenSourceTimestampsAreIrregular();

private:
    QTemporaryDir m_tempDir;
};

void tst_QMediaRecorderBackend::cleanupTestCase()
{
    // Copy any stored files in the temporary directory over to COIN result directory
    // to allow inspecting image differences.
    if (qEnvironmentVariableIsSet("COIN_CTEST_RESULTSDIR")) {
        const QDir sourceDir = m_tempDir.path();
        const QDir resultsDir{ qEnvironmentVariable("COIN_CTEST_RESULTSDIR") };
        if (!copyAllFiles(sourceDir, resultsDir))
            qWarning() << "Failed to copy files to COIN_CTEST_RESULTSDIR";
    }
}

void tst_QMediaRecorderBackend::record_createsFileWithExpectedExtension_whenRecordingAudio_data()
{
    QTest::addColumn<QMediaFormat::FileFormat>("fileFormat");
    QTest::addColumn<QString>("inputFileName");
    QTest::addColumn<QString>("expectedFileName");

    QMediaFormat format;
    for (const QMediaFormat::FileFormat &fileFormat :
         format.supportedFileFormats(QMediaFormat::Encode)) {

        const QByteArray formatName = QMediaFormat::fileFormatName(fileFormat).toLatin1();

        {
            // Verify that extension is appended if not already present
            QByteArray testName = formatName + " without extension";
            QString inputFileName = u"filename"_s;
            QString expected = inputFileName;

            const QMediaFormat mediaFormat(fileFormat);
            const QMimeType mimeType = mediaFormat.mimeType();
            const QString preferredExt = mimeType.preferredSuffix();
            if (!preferredExt.isEmpty())
                expected += "." + preferredExt;

            QTest::addRow("%s", testName.data()) << fileFormat << inputFileName << expected;
        }

        {
            // Verify that default extension is not appended when extension is wrong
            QByteArray testName = formatName + " with wrong extension";
            QString inputFileName = u"filename.mp4"_s;
            QString expected = u"filename.mp4"_s;

            QTest::addRow("%s", testName.data()) << fileFormat << inputFileName << expected;
        }
    }
}

void tst_QMediaRecorderBackend::record_createsFileWithExpectedExtension_whenRecordingAudio()
{
    QSKIP_IF_NOT_FFMPEG("This test requires APIs that are only implemented with FFmpeg media backend");

    QFETCH(const QMediaFormat::FileFormat, fileFormat);
    QFETCH(const QString, inputFileName);
    QFETCH(const QString, expectedFileName);

    QTemporaryDir tempDir;

    const QUrl url = QUrl::fromLocalFile(tempDir.filePath(inputFileName));

    QMediaCaptureSession session;

    QMediaRecorder recorder;
    recorder.setOutputLocation(url);
    recorder.setMediaFormat({ fileFormat });

    QAudioFormat format;
    format.setChannelConfig(QAudioFormat::ChannelConfigMono);
    format.setChannelCount(1);
    format.setSampleFormat(QAudioFormat::Float);
    format.setSampleRate(44100);

    QAudioBufferInput input{ format };
    session.setAudioBufferInput(&input);
    session.setRecorder(&recorder);

    AudioGenerator generator;
    generator.setFormat(format);
    generator.setDuration(1s);
    generator.emitEmptyBufferOnStop();

    bool done = false;
    connect(&recorder, &QMediaRecorder::recorderStateChanged, &recorder,
            [&](QMediaRecorder::RecorderState state) {
                if (state == QMediaRecorder::StoppedState)
                    done = true;
            });

    connect(&input, &QAudioBufferInput::readyToSendAudioBuffer, //
            &generator, &AudioGenerator::nextBuffer);

    connect(&generator, &AudioGenerator::audioBufferCreated, //
            &input, &QAudioBufferInput::sendAudioBuffer);

    recorder.setAutoStop(true);

    recorder.record();

    QTRY_VERIFY_WITH_TIMEOUT(done, 60s); // Timeout can be as large as possible

    const QUrl loc = recorder.actualLocation();

    const bool pass = loc.toString().endsWith(expectedFileName);
    if (!pass)
        qWarning() << loc << "does not match expected " << expectedFileName;

    QVERIFY(pass);
}

void tst_QMediaRecorderBackend::record_writesVideo_whenInputFrameShrinksOverTime()
{
    QSKIP_IF_NOT_FFMPEG();

    CaptureSessionFixture f{ StreamType::Video };
    f.start(RunMode::Push, AutoStop::EmitEmpty);
    f.readyToSendVideoFrame.wait();

    constexpr int startSize = 38;
    int frameCount = 0;
    for (int i = 0; i < startSize; ++i) {
        ++frameCount;
        const QSize size{ startSize - i, startSize - i };
        f.m_videoGenerator.setSize(size);
        QVERIFY(f.m_videoInput.sendVideoFrame(f.m_videoGenerator.createFrame()));
        f.readyToSendVideoFrame.wait();
    }

    f.m_videoInput.sendVideoFrame({});

    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    auto info = MediaInfo::create(f.m_recorder.actualLocation());

    QCOMPARE_EQ(info->m_frameCount, frameCount);

    // All frames should be resized to the size of the first frame
    QCOMPARE_EQ(info->m_size, QSize(startSize, startSize));
}

void tst_QMediaRecorderBackend::record_writesVideo_whenInputFrameGrowsOverTime()
{
    QSKIP_IF_NOT_FFMPEG();

    CaptureSessionFixture f{ StreamType::Video };
    f.start(RunMode::Push, AutoStop::EmitEmpty);
    f.readyToSendVideoFrame.wait();

    constexpr int startSize = 38;
    constexpr int maxSize = 256;
    int frameCount = 0;

    for (int i = 0; i < maxSize - startSize; ++i) {
        ++frameCount;
        const QSize size{ startSize + i, startSize + i };
        f.m_videoGenerator.setPattern(ImagePattern::ColoredSquares);
        f.m_videoGenerator.setSize(size);
        QVERIFY(f.m_videoInput.sendVideoFrame(f.m_videoGenerator.createFrame()));
        f.readyToSendVideoFrame.wait();
    }

    f.m_videoInput.sendVideoFrame({});

    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    auto info = MediaInfo::create(f.m_recorder.actualLocation());

    QCOMPARE_EQ(info->m_frameCount, frameCount);

    // All frames should be resized to the size of the first frame
    QCOMPARE_EQ(info->m_size, QSize(startSize, startSize));
}

void tst_QMediaRecorderBackend::record_stopsRecording_whenInputsReportedEndOfStream_data()
{
    QTest::addColumn<bool>("audioStopsFirst");

    QTest::addRow("audio stops first") << true;
    QTest::addRow("video stops first") << true;
}

void tst_QMediaRecorderBackend::record_stopsRecording_whenInputsReportedEndOfStream()
{
    QSKIP_IF_NOT_FFMPEG();

    QFETCH(const bool, audioStopsFirst);

    CaptureSessionFixture f{ StreamType::AudioAndVideo };
    f.m_recorder.setAutoStop(true);

    f.m_audioGenerator.setBufferCount(30);
    f.m_videoGenerator.setFrameCount(30);

    QSignalSpy audioDone{ &f.m_audioGenerator, &AudioGenerator::done };
    QSignalSpy videoDone{ &f.m_videoGenerator, &VideoGenerator::done };

    f.start(RunMode::Pull, AutoStop::No);

    audioDone.wait();
    videoDone.wait();

    if (audioStopsFirst) {
        f.m_audioInput.sendAudioBuffer({});
        QVERIFY(!f.waitForRecorderStopped(300ms)); // Should not stop until both streams stopped
        QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
                 f.m_recorder.errorString().toLatin1().constData());
        f.m_videoInput.sendVideoFrame({});
    } else {
        f.m_videoInput.sendVideoFrame({});
        QVERIFY(!f.waitForRecorderStopped(300ms)); // Should not stop until both streams stopped
        QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
                 f.m_recorder.errorString().toLatin1().constData());
        f.m_audioInput.sendAudioBuffer({});
    }

    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    // check if the file has been written

    const std::optional<MediaInfo> mediaInfo = MediaInfo::create(f.m_recorder.actualLocation());

    QVERIFY(mediaInfo);
    QVERIFY(mediaInfo->m_hasVideo);
    QVERIFY(mediaInfo->m_hasAudio);
}

void tst_QMediaRecorderBackend::record_writesVideo_withoutTransforms_whenPresentationTransformsPresent_data()
{
    QTest::addColumn<QVideoFrameFormat::PixelFormat>("pixelFormat");
    QTest::addColumn<QtVideo::Rotation>("presentationRotation");
    QTest::addColumn<bool>("presentationMirrored");

    const std::array pixelFormats = { QVideoFrameFormat::Format_BGRA8888,
                                      QVideoFrameFormat::Format_Jpeg };

    for (const QVideoFrameFormat::PixelFormat pixelFormat : pixelFormats) {
        const QByteArray formatName =
                QVideoFrameFormat::pixelFormatToString(pixelFormat).toLatin1();

        auto addRow = [&](const char *description, QtVideo::Rotation rotation, bool mirrored) {
            QTest::addRow("%s, %s", formatName.data(), description)
                    << pixelFormat << rotation << mirrored;
        };

        addRow("No rotation, not mirrored", QtVideo::Rotation::None, false);
        addRow("90 degrees, not mirrored", QtVideo::Rotation::Clockwise90, false);
        addRow("180 degrees, not mirrored", QtVideo::Rotation::Clockwise180, false);
        addRow("270 degrees, not mirrored", QtVideo::Rotation::Clockwise270, false);
        addRow("No rotation, mirrored", QtVideo::Rotation::None, true);
        addRow("90 degrees, mirrored", QtVideo::Rotation::Clockwise90, true);
        addRow("180 degrees, mirrored", QtVideo::Rotation::Clockwise180, true);
        addRow("270 degrees, mirrored", QtVideo::Rotation::Clockwise270, true);
    }
}

void tst_QMediaRecorderBackend::record_writesVideo_withoutTransforms_whenPresentationTransformsPresent()
{
    QSKIP_IF_NOT_FFMPEG();

    QFETCH(const QVideoFrameFormat::PixelFormat, pixelFormat);
    QFETCH(const QtVideo::Rotation, presentationRotation);
    QFETCH(const bool, presentationMirrored);

    CaptureSessionFixture f{ StreamType::Video };
    f.m_videoGenerator.setPixelFormat(pixelFormat);
    f.m_videoGenerator.setPattern(ImagePattern::ColoredSquares);
    f.m_videoGenerator.setFrameCount(3);

    f.m_videoGenerator.setPresentationRotation(presentationRotation);
    f.m_videoGenerator.setPresentationMirrored(presentationMirrored);

    f.start(RunMode::Pull, AutoStop::EmitEmpty);
    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    const auto info = MediaInfo::create(f.m_recorder.actualLocation());
    QCOMPARE_EQ(info->m_colors.size(), 3u);

    std::array<QColor, 4> colors = info->m_colors.front();
    QVERIFY(fuzzyCompare(colors[0], Qt::red));
    QVERIFY(fuzzyCompare(colors[1], Qt::green));
    QVERIFY(fuzzyCompare(colors[2], Qt::blue));
    QVERIFY(fuzzyCompare(colors[3], Qt::yellow));
}

void tst_QMediaRecorderBackend::record_writesVideo_withCorrectColors_data()
{
    QTest::addColumn<QVideoFrameFormat::PixelFormat>("pixelFormat");

    for (int i = QVideoFrameFormat::Format_ARGB8888; i < QVideoFrameFormat::NPixelFormats; ++i) {
        const auto format = static_cast<QVideoFrameFormat::PixelFormat>(i);
        if (!isSupportedPixelFormat(format))
            continue;
        const QByteArray formatName = QVideoFrameFormat::pixelFormatToString(format).toLatin1();
        QTest::addRow("%s", formatName.data()) << format;
    }
}

void tst_QMediaRecorderBackend::record_writesVideo_withCorrectColors()
{
    QSKIP_IF_NOT_FFMPEG();

    QFETCH(const QVideoFrameFormat::PixelFormat, pixelFormat);

    // Arrange
    CaptureSessionFixture f{ StreamType::Video };
    f.m_videoGenerator.setPixelFormat(pixelFormat);
    f.m_videoGenerator.setPattern(ImagePattern::ColoredSquares);
    f.m_videoGenerator.setFrameCount(1);
    f.m_videoGenerator.setSize({ 128, 64 }); // Small frames to speed up test

    f.start(RunMode::Push, AutoStop::EmitEmpty);

    // Act: Push one frame through and send sentinel stop frame
    f.readyToSendVideoFrame.wait();
    f.m_videoGenerator.nextFrame();
    f.readyToSendVideoFrame.wait();
    f.m_videoGenerator.nextFrame();

    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             qPrintable(f.m_recorder.errorString()));

    const auto info = MediaInfo::create(f.m_recorder.actualLocation(), /*keep frames */ true);

    const QImage expectedImage = f.m_videoGenerator.createFrame().toImage();

    QCOMPARE_EQ(info->m_frames.size(), 2u); // Front has content, back is empty
    const QImage actualImage = info->m_frames.front().toImage();

    // Store images to simplify debugging/verifying output
    const QString path = m_tempDir.filePath(QVideoFrameFormat::pixelFormatToString(pixelFormat));
    QVERIFY(expectedImage.save(path + "_expected.png"));
    QVERIFY(actualImage.save(path + "_actual.png"));

    // Extract center of each quadrant, because recorder compression introduces artifacts
    // in color boundaries.
    const std::array<QColor, 4> expectedColors = MediaInfo::sampleQuadrants(expectedImage);
    const std::array<QColor, 4> actualColors = MediaInfo::sampleQuadrants(actualImage);

    // Assert that colors are similar (not exactly the same because compression introduces minor
    // differences)

#ifdef Q_OS_MACOS
    // workaround for hevc_videotoolbox
    // YUV420P may encode to 10-bit yuv420p10le, which has more quantization artifacts when
    // converting from 8-bit source frames. Use higher tolerance for all quadrants in this case.
    const float tolerance = (pixelFormat == QVideoFrameFormat::Format_YUV420P) ? 0.05f : 0.01f;
#else
    const float tolerance = 0.01f;
#endif

    auto verifyQuadrantColor = [&](int quadrant) {
        QVERIFY2(fuzzyCompare(expectedColors[quadrant], actualColors[quadrant], tolerance),
                 qPrintable(QString("Quadrant %1 color mismatch for %2. "
                                    "Expected RGBA(%3,%4,%5,%6), got RGBA(%7,%8,%9,%10)")
                                    .arg(quadrant)
                                    .arg(QVideoFrameFormat::pixelFormatToString(pixelFormat))
                                    .arg(expectedColors[quadrant].red())
                                    .arg(expectedColors[quadrant].green())
                                    .arg(expectedColors[quadrant].blue())
                                    .arg(expectedColors[quadrant].alpha())
                                    .arg(actualColors[quadrant].red())
                                    .arg(actualColors[quadrant].green())
                                    .arg(actualColors[quadrant].blue())
                                    .arg(actualColors[quadrant].alpha())));
    };

    verifyQuadrantColor(0);
    verifyQuadrantColor(1);
    verifyQuadrantColor(2);
    verifyQuadrantColor(3);
}

void tst_QMediaRecorderBackend::actualLocation_returnsNonEmptyLocation_whenRecorderEntersRecordingState()
{
    QSKIP_IF_NOT_FFMPEG();

    const QUrl url = QUrl::fromLocalFile(m_tempDir.filePath("any_file_name"));
    CaptureSessionFixture f{ StreamType::AudioAndVideo };
    f.m_recorder.setOutputLocation(url);

    auto onStateChanged = [&f](QMediaRecorder::RecorderState state) {
        QCOMPARE(state, QMediaRecorder::RecordingState);
        QCOMPARE_NE(f.m_recorder.actualLocation(), QUrl());
    };

    connect(&f.m_recorder, &QMediaRecorder::recorderStateChanged, this, onStateChanged,
            Qt::SingleShotConnection);

    QCOMPARE(f.m_recorder.actualLocation(), QUrl());
    f.start(RunMode::Pull, AutoStop::EmitEmpty);

    QTRY_COMPARE(f.m_recorder.recorderState(), QMediaRecorder::RecordingState);
    f.m_recorder.stop();
}

void tst_QMediaRecorderBackend::record_writesToOutputDevice_whenWritableOutputDeviceAndLocationAreSet()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    const QUrl url = QUrl::fromLocalFile(m_tempDir.filePath("file_to_be_not_created.mp4"));
    CaptureSessionFixture f{ StreamType::Audio };
    f.m_recorder.setOutputLocation(url);

    QTemporaryFile tempFile;
    QTEST_ASSERT(tempFile.open());

    f.m_recorder.setOutputDevice(&tempFile);

    // Act
    f.start(RunMode::Pull, AutoStop::EmitEmpty);

    QVERIFY(f.waitForRecorderStopped(60s));
    tempFile.close();

    // Assert
    QVERIFY(!QFileInfo::exists(url.toLocalFile()));
    QCOMPARE(f.m_recorder.actualLocation(), QUrl());
    QCOMPARE_GT(tempFile.size(), 0);
}

void tst_QMediaRecorderBackend::record_writesToOutputLocation_whenNotWritableOutputDeviceAndLocationAreSet()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    CaptureSessionFixture f{ StreamType::Audio };

    const QUrl url = QUrl::fromLocalFile(m_tempDir.filePath("file_to_be_not_created.mp4"));

    QTemporaryFile tempFile;
    f.m_recorder.setOutputDevice(&tempFile);
    f.m_recorder.setOutputLocation(url);

    // Act
    f.start(RunMode::Pull, AutoStop::EmitEmpty);

    QVERIFY(f.waitForRecorderStopped(60s));
    tempFile.close();

    // Assert
    const QString actualLocation = f.m_recorder.actualLocation().toLocalFile();
    QVERIFY(QFileInfo::exists(actualLocation));
    QCOMPARE_GT(QFileInfo(actualLocation).size(), 0);
    QCOMPARE_NE(f.m_recorder.actualLocation(), QUrl());
    QCOMPARE(tempFile.size(), 0);
}

void tst_QMediaRecorderBackend::record_writesVideo_withAllSupportedVideoFormats_data()
{
    QTest::addColumn<QMediaFormat>("format");

    for (QMediaFormat f : allFileFormats(/*with unspecified*/ true)) {
        for (const QMediaFormat::VideoCodec &c : allVideoCodecs(/*with unspecified*/ true)) {
            f.setVideoCodec(c);

            if (!f.isSupported(QMediaFormat::Encode))
                continue;

            const auto formatName = QMediaFormat::fileFormatName(f.fileFormat()).toLatin1();
            const auto videoCodecName = QMediaFormat::videoCodecName(f.videoCodec()).toLatin1();

            QTest::addRow("%s,%s", formatName.data(), videoCodecName.data()) << f;
        }
    }
}

void tst_QMediaRecorderBackend::record_writesVideo_withAllSupportedVideoFormats()
{
    if (!isFFMPEGPlatform())
        QSKIP("Tested only with FFmpeg backend because other backends don't have the same "
              "format support");

    QFETCH(const QMediaFormat, format);

    CaptureSessionFixture f{ StreamType::Video };

    f.m_recorder.setMediaFormat(format);
    f.m_videoGenerator.setPattern(ImagePattern::ColoredSquares);
    f.m_videoGenerator.setFrameCount(3);
    f.m_videoGenerator.setFrameRate(24);
    f.m_videoGenerator.setSize({ 128, 64 });

    f.start(RunMode::Pull, AutoStop::EmitEmpty);

    const auto actualFormat = f.m_recorder.mediaFormat();

    qDebug() << "Actual format used: " << QMediaFormat::fileFormatName(actualFormat.fileFormat())
             << "/" << QMediaFormat::videoCodecName(actualFormat.videoCodec());

    QVERIFY(f.waitForRecorderStopped(60s));

    if (unsupportedVideoCodecs(actualFormat.fileFormat()).count(actualFormat.videoCodec()))
        QEXPECT_FAIL("", "QTBUG-126276", Abort);

    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().data());

    const std::optional<MediaInfo> info = MediaInfo::create(f.m_recorder.actualLocation());
    QVERIFY(info);

    QCOMPARE_GE(info->m_colors.size(),
                2u); // TODO: We loose one frame with some combinations
    QCOMPARE_LE(info->m_colors.size(), 3u);

    std::array<QColor, 4> colors = info->m_colors.front();
    QVERIFY(fuzzyCompare(colors[0], Qt::red));
    QVERIFY(fuzzyCompare(colors[1], Qt::green));
    QVERIFY(fuzzyCompare(colors[2], Qt::blue));
    QVERIFY(fuzzyCompare(colors[3], Qt::yellow));
}

void tst_QMediaRecorderBackend::record_writesAudio_withAllSupportedAudioFormats_data()
{
    QTest::addColumn<QMediaFormat>("format");

    for (QMediaFormat f : allFileFormats(/*with unspecified*/ true)) {
        for (const QMediaFormat::AudioCodec &c : allAudioCodecs(/*with unspecified*/ true)) {
            f.setAudioCodec(c);

            if (!f.isSupported(QMediaFormat::Encode))
                continue;

#ifdef Q_OS_WIN
            // QTBUG-147882: Wave audio codec with container formats other than Wave is broken on Windows
            if (c == QMediaFormat::AudioCodec::Wave
                && f.fileFormat() != QMediaFormat::Wave) {
                continue;
            }
#endif

            const auto formatName = QMediaFormat::fileFormatName(f.fileFormat()).toLatin1();
            const auto audioCodecName = QMediaFormat::audioCodecName(f.audioCodec()).toLatin1();

            QTest::addRow("%s,%s", formatName.data(), audioCodecName.data()) << f;
        }
    }
}

void tst_QMediaRecorderBackend::record_writesAudio_withAllSupportedAudioFormats()
{
    if (!isFFMPEGPlatform())
        QSKIP("Tested only with FFmpeg backend because other backends don't have the same "
              "format support");

    QFETCH(const QMediaFormat, format);

    CaptureSessionFixture f{ StreamType::Audio };
    f.m_recorder.setMediaFormat(format);

    QAudioFormat audioFormat;
    audioFormat.setSampleRate(44100); // TODO: Change to 8000 fails some tests
    audioFormat.setSampleFormat(QAudioFormat::Float);
    audioFormat.setChannelConfig(
            QAudioFormat::ChannelConfigStereo); // TODO: Change to Mono fails some tests
    f.m_audioGenerator.setFormat(audioFormat);

    constexpr seconds expectedDuration = 1s;

    f.m_audioGenerator.setDuration(expectedDuration);
    f.m_audioGenerator.setFrequency(800);

    f.start(RunMode::Pull, AutoStop::EmitEmpty);

    const auto actualFormat = f.m_recorder.mediaFormat();

    qDebug() << "Actual format: " << QMediaFormat::fileFormatName(actualFormat.fileFormat()) << ","
             << QMediaFormat::audioCodecName(actualFormat.audioCodec());

    QVERIFY(f.waitForRecorderStopped(60s));

    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().data());

    const std::optional<MediaInfo> info = MediaInfo::create(f.m_recorder.actualLocation());
    QVERIFY(info);

    QCOMPARE_GE(info->m_audioBuffer.duration(),
                std::chrono::microseconds(expectedDuration / 5).count()); // TODO: Fix cut audio

    QCOMPARE_GE(info->m_audioBuffer.byteCount(), 1u); // TODO: Verify with QSineWaveValidator
}

// TODO: Add test that verifies format support with both audio and video in the same recording

void tst_QMediaRecorderBackend::record_emits_mediaformatChanged_whenFormatChanged()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    CaptureSessionFixture f{ StreamType::Video };
    f.m_videoGenerator.setFrameCount(1);
    f.m_videoGenerator.setSize({ 128, 64 }); // Small frames to speed up test

    QMediaFormat unspecifiedFormat;
    f.m_recorder.setMediaFormat(unspecifiedFormat);

    f.start(RunMode::Pull, AutoStop::EmitEmpty);

    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().data());

    QCOMPARE_EQ(f.mediaFormatChanged.size(), 1);

    const QMediaFormat actualFormat = f.m_recorder.mediaFormat();
    QCOMPARE_NE(actualFormat.fileFormat(), QMediaFormat::UnspecifiedFormat);
    QCOMPARE_NE(actualFormat.videoCodec(), QMediaFormat::VideoCodec::Unspecified);
    QCOMPARE_NE(actualFormat.audioCodec(), QMediaFormat::AudioCodec::Unspecified);
}

void tst_QMediaRecorderBackend::stop_stopsRecording_whenInvokedUponRecordingStart()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    const QUrl url = QUrl::fromLocalFile(m_tempDir.filePath("any_file_name"));
    CaptureSessionFixture f{ StreamType::AudioAndVideo };
    f.m_recorder.setOutputLocation(url);

    auto onStateChanged = [&f](QMediaRecorder::RecorderState state) {
        if (state == QMediaRecorder::RecordingState)
            f.m_recorder.stop();
    };

    connect(&f.m_recorder, &QMediaRecorder::recorderStateChanged, this, onStateChanged);

    // Act
    f.start(RunMode::Pull, AutoStop::No);

    // Assert
    QTRY_COMPARE(f.m_recorder.recorderState(), QMediaRecorder::StoppedState);
    QList< QList<QVariant> > expectedRecorderStateChangedSignals( { { QMediaRecorder::RecordingState }, { QMediaRecorder::StoppedState } });
    QCOMPARE(f.recorderStateChanged, expectedRecorderStateChangedSignals);
}

void tst_QMediaRecorderBackend::record_reflectsAudioEncoderSetting()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    CaptureSessionFixture f{ StreamType::Audio };

    QAudioFormat audioFormat;
    audioFormat.setSampleFormat(QAudioFormat::Float);
    audioFormat.setChannelCount(2);
    audioFormat.setSampleRate(44100);
    f.m_audioGenerator.setFormat(audioFormat);

    QMediaFormat fmt{ QMediaFormat::Wave };
    fmt.setAudioCodec(QMediaFormat::AudioCodec::Wave);
    f.m_recorder.setMediaFormat(fmt);
    f.m_recorder.setAudioSampleRate(24000); // nonstandard sampling rate
    f.m_recorder.setAudioChannelCount(1); // mono

    // act
    f.start(RunMode::Pull, AutoStop::EmitEmpty);
    QVERIFY(f.waitForRecorderStopped(60s));

    // Assert
    auto info = MediaInfo::create(f.m_recorder.actualLocation());
    QVERIFY(info);
    QCOMPARE_EQ(info->m_audioBuffer.format().sampleRate(), 24000);
    QCOMPARE_EQ(info->m_audioBuffer.format().channelCount(), 1);
}

void tst_QMediaRecorderBackend::record_adjustsFrameRate_whenSourceFrameRateDifferentFromEncoderFrameRate_data()
{
    QTest::addColumn<double>("sourceFrameRate");
    QTest::addColumn<double>("recorderFrameRate");
    QTest::addColumn<bool>("reportsVariableRate");
    QTest::addColumn<bool>("reportsEndTime");

    struct TestCase {
        const char *name;
        double sourceRate;
        double recorderRate;
    };

    const TestCase cases[] = {
        { "the default rate from 10.0", 10.0, 0.0 },
        { "the default rate from 59.94", 59.94, 0.0 },
        { "the default rate from 75.0", 75.0, 0.0 },
        { "the same rate", 10.0, 10.0 },
        { "a half rate", 20.0, 10.0 },
        { "a double rate", 5.0, 10.0 },
        { "a 2/3 rate", 15.0, 10.0 },
        { "a 3/2 rate", 10.0, 15.0 },
    };

    // TODO: Add cases for different video codecs
    for (const auto &c : cases) {
        for (bool variableRate : { false, true }) {
            for (bool reportsEndTime : { true, false }) {
                const char *prefix = variableRate ? "Encode variable stream at " : "Encode at ";
                const char *suffix = reportsEndTime ? "" : " (invalid endTime)";
                QTest::addRow("%s%s%s", prefix, c.name, suffix)
                    << c.sourceRate << c.recorderRate << variableRate << reportsEndTime;
            }
        }
    }
}

void tst_QMediaRecorderBackend::record_adjustsFrameRate_whenSourceFrameRateDifferentFromEncoderFrameRate()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    QFETCH(double, sourceFrameRate);
    QFETCH(double, recorderFrameRate);
    QFETCH(bool, reportsVariableRate);
    QFETCH(bool, reportsEndTime);

    if (sourceFrameRate > 60.0 && recorderFrameRate <= 0.0 && !reportsVariableRate)
        // Tries to set up codec with fps=75., which fails on MPEG. Skip for now
        // TODO: We should detect this and adjust videoFrameRate
        QSKIP("Codec fails when setting too high frame rate");

    // Set up for 1s of frames
    using namespace std::chrono;
    const int sourceFrameCount = qFloor(sourceFrameRate);
    const auto periodDuration = microseconds(std::lround(1e6 / sourceFrameRate));
    const bool frameRateIsSet = recorderFrameRate > 0.0;
    const double targetFrameRate = frameRateIsSet ? recorderFrameRate : sourceFrameRate;

    // Frame rate adapter active when the target rate is explicitly set or the source rate is known
    const bool adapterActive = frameRateIsSet || !reportsVariableRate;
    int expectedFrameCount = adapterActive ? qFloor(targetFrameRate) : sourceFrameCount;

    // Adapter slot and epsilon mirror the adapter's internal values; used for expected frame count
    // and source frame index calculation. Only used when adapterActive == true
    const auto slotDuration = microseconds(static_cast<qint64>(1e6 / targetFrameRate));
    const auto epsilon = reportsVariableRate
            ? 1000us
            : microseconds(std::lround(periodDuration.count() * 0.05)); // 5% of source period

    if (adapterActive && reportsVariableRate && !reportsEndTime) {
        // We fill gaps in encoding slots after receiving frames, so expected frame count is different
        if (targetFrameRate < sourceFrameRate) {
            // Flush produces an extra frame only if the last source frame doesn't land on a slot boundary
            const auto lastFrameTime = (sourceFrameCount - 1) * periodDuration;
            if (lastFrameTime % slotDuration > epsilon)
                expectedFrameCount++;
        } else {
            int copies = qFloor(targetFrameRate / sourceFrameRate);
            if (copies > 1)
                expectedFrameCount -= copies - 1; // Flushing ensures only one encoding slot for the last frame
        }
    }

    const std::array<QColor, 5> sourceColors = { Qt::red, Qt::green, Qt::blue, Qt::yellow, Qt::magenta };

    std::vector<QColor> expectedColors;
    expectedColors.reserve(expectedFrameCount);
    for (int i = 0; i < expectedFrameCount; ++i) {
        // Source frame index: passthrough = direct; adapter active = slot-aligned, clamped
        const int sourceIndex = adapterActive
                ? qMin(static_cast<int>((i * slotDuration + epsilon) / periodDuration),
                       sourceFrameCount - 1)
                : i;
        expectedColors.push_back(sourceColors[sourceIndex % static_cast<int>(sourceColors.size())]);
    }

    CaptureSessionFixture f{ StreamType::Video };
    if (frameRateIsSet)
        f.m_recorder.setVideoFrameRate(recorderFrameRate);
    if (!reportsVariableRate)
        f.m_videoGenerator.setFrameRate(sourceFrameRate);
    f.m_videoGenerator.setPeriod(periodDuration);
    f.m_videoGenerator.setEndTimeReporting(reportsEndTime);
    f.m_videoGenerator.setFrameCount(sourceFrameCount);
    f.m_videoGenerator.setSize({ 128, 64 });
    f.m_videoGenerator.setColors(sourceColors);

    // Act
    f.start(RunMode::Pull, AutoStop::EmitEmpty);
    QVERIFY(f.waitForRecorderStopped(60s));

    // Assert
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    const auto info = MediaInfo::create(f.m_recorder.actualLocation());
    QVERIFY(info);

    QCOMPARE_GE(info->m_duration, 900ms);
    QCOMPARE_LE(info->m_duration, 1100ms);

    verifyEncodedFrames(*info, expectedColors);
}

void tst_QMediaRecorderBackend::record_writesLastFrame_whenAdapterHadBufferedItBeforeEndOfStream()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    CaptureSessionFixture f{ StreamType::Video };
    f.m_recorder.setVideoFrameRate(10.0);
    f.m_videoGenerator.setSize({ 64, 64 });

    // Act
    f.start(RunMode::Push, AutoStop::EmitEmpty);
    f.readyToSendVideoFrame.wait();

    // Frame 1 at t=0: emitted immediately for slot 0
    {
        QVideoFrame frame = f.m_videoGenerator.createFrame();
        frame.setStartTime(0);
        QVERIFY(f.m_videoInput.sendVideoFrame(frame));
        f.readyToSendVideoFrame.wait();
    }

    // Frame 2 at t=60ms: before the 100ms slot, no endTime -> adapter buffers without emitting
    {
        QVideoFrame frame = f.m_videoGenerator.createFrame();
        frame.setStartTime(60'000); // µs
        QVERIFY(f.m_videoInput.sendVideoFrame(frame));
        f.readyToSendVideoFrame.wait();
    }

    // EOS: flush emits the buffered frame before the encoder stops
    f.m_videoInput.sendVideoFrame({});

    // Assert
    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    const auto info = MediaInfo::create(f.m_recorder.actualLocation());
    QVERIFY(info);
    QCOMPARE_EQ(info->m_frameCount, 2);
}

void tst_QMediaRecorderBackend::record_encodesFrames_whenSourceTimestampsAreIrregular_data()
{
    QTest::addColumn<double>("frameRate");
    QTest::addColumn<std::vector<QColor>>("expectedColors");

    // Source frames cycle through Red, Green, Blue, Yellow, Magenta
    // with irregular periods (see test).

    QTest::addRow("Default frame rate")
            << 0.0 << std::vector<QColor>{ Qt::red,     Qt::green, Qt::blue,  Qt::yellow,
                                           Qt::magenta, Qt::red,   Qt::green, Qt::blue };

    // 100ms period, 10 frames
    QTest::addRow("Fixed frame rate (10 fps)")
            << 10.0
            << std::vector<QColor>{ Qt::red,     Qt::green, Qt::blue,  Qt::yellow, Qt::yellow,
                                    Qt::magenta, Qt::red,   Qt::green, Qt::green,  Qt::blue };

    // 200ms period, 5 frames + flushed last frame
    QTest::addRow("Fixed frame rate (5fps)")
            << 5.0
            << std::vector<QColor>{ Qt::red, Qt::blue, Qt::yellow, Qt::red, Qt::green, Qt::blue };
}

void tst_QMediaRecorderBackend::record_encodesFrames_whenSourceTimestampsAreIrregular()
{
    QSKIP_IF_NOT_FFMPEG();

    // Arrange
    QFETCH(double, frameRate);
    QFETCH(std::vector<QColor>, expectedColors);

    // Irregular start times spanning 1s
    // 0    red
    // 45   green
    // 180  blue
    // 225  yellow
    // 410  magenta
    // 600  red
    // 650  green
    // 900  blue
    using namespace std::chrono;
    const std::array<microseconds, 8> startTimes = { 0ms,   45ms,  180ms, 225ms,
                                                     410ms, 600ms, 650ms, 900ms };

    CaptureSessionFixture f{ StreamType::Video };
    f.m_videoGenerator.setSize({ 64, 64 });
    f.m_videoGenerator.setColors({ Qt::red, Qt::green, Qt::blue, Qt::yellow, Qt::magenta });
    if (frameRate > 0.0)
        f.m_recorder.setVideoFrameRate(frameRate);

    QSignalSpy frameCreated{ &f.m_videoGenerator, &VideoGenerator::frameCreated };

    // Act
    f.start(RunMode::Push, AutoStop::EmitEmpty);

    // Disconnect frameCreated from video input so the test can override each frame's timestamp
    // before pushing it manually.
    QObject::disconnect(&f.m_videoGenerator, &VideoGenerator::frameCreated, &f.m_videoInput,
                         &QVideoFrameInput::sendVideoFrame);

    f.readyToSendVideoFrame.wait();

    for (microseconds startTime : startTimes) {
        f.m_videoGenerator.nextFrame(); // advances the source frame index/color, emits frameCreated
        QVideoFrame frame = frameCreated.takeFirst().at(0).value<QVideoFrame>();
        frame.setStartTime(startTime.count());
        QVERIFY(f.m_videoInput.sendVideoFrame(frame));
        f.readyToSendVideoFrame.wait();
    }
    f.m_videoInput.sendVideoFrame({}); // EOS

    // Assert
    QVERIFY(f.waitForRecorderStopped(60s));
    QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
             f.m_recorder.errorString().toLatin1().constData());

    const auto info = MediaInfo::create(f.m_recorder.actualLocation());
    QVERIFY(info);

    verifyEncodedFrames(*info, expectedColors);
}

QTEST_MAIN(tst_QMediaRecorderBackend)

#include "tst_qmediarecorderbackend.moc"
