// Standalone tests for reasampler::wav_codec — no REAPER, no test framework.
// The ONE pure WAV/RIFF owner (Q-W3, audit §4e): builds synthetic 32-bit-float WAV
// byte buffers, asserts the parse geometry, the float extraction, the truncate-plan
// arithmetic + size-field patch, the float32 build round-trip, and the WAV-aware
// content hashes (moved here from capture_paths with the hash implementations).
//
// Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped;
// format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame
// extraction (whole / tail window / clamp / out-of-range); truncate plan (kept<all,
// no-op keep-all, kept==0, grow rejected) with exact size-field values; patchU32LE;
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
// determinism, metadata-skip, fallback, and domain separation; a golden hash
// literal pinning exact hex output for a fixed input (guards persisted
// contentHash values against a silent feed-sequence drift); and the lossless mono
// collapse (bit-identical N-channel fold, the one-sample-differs and signed-zero
// declines, already-mono, zero/single-frame, an odd padded leading chunk, and the
// content-hash consequence) plus its buffer-side wrapper applyMonoCollapse (the
// bytes/layout pairing, the byte-identical decline, one-ULP, and double-apply).

#include "../src/core/capture/wav_codec.h"

#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>

using namespace reasampler;
using namespace reasampler::capture;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

// --- Synthetic WAV builder ---------------------------------------------------

static void putU16(std::vector<std::uint8_t>& b, std::uint16_t v) {
    b.push_back(static_cast<std::uint8_t>(v & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
}
static void putU32(std::vector<std::uint8_t>& b, std::uint32_t v) {
    b.push_back(static_cast<std::uint8_t>(v & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
    b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
static void putTag(std::vector<std::uint8_t>& b, const char* t) {
    for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
}
static void putFloat(std::vector<std::uint8_t>& b, float f) {
    std::uint8_t tmp[4];
    std::memcpy(tmp, &f, 4);
    for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
}
static float floatFromBits(std::uint32_t bits) {
    float f;
    std::memcpy(&f, &bits, 4);
    return f;
}
static std::uint32_t bitsFromFloat(float f) {
    std::uint32_t bits;
    std::memcpy(&bits, &f, 4);
    return bits;
}

// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding
// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an
// unknown chunk before fmt to exercise the chunk walk. Samples: frame f, channel c
// = value(f,c).
template <typename Fn>
static std::vector<std::uint8_t> buildFloatWav(std::uint16_t channels,
                                               std::uint32_t sampleRate,
                                               std::size_t frames,
                                               Fn value,
                                               bool leadingJunk = false,
                                               std::uint16_t fmtTag = 3,
                                               std::uint16_t bits = 32) {
    const std::uint32_t dataBytes =
        static_cast<std::uint32_t>(frames * channels * (bits / 8));

    std::vector<std::uint8_t> chunks;  // everything after "WAVE"
    if (leadingJunk) {
        putTag(chunks, "LIST");
        putU32(chunks, 4);
        putTag(chunks, "INFO");  // 4-byte body, even -> no pad
    }
    // fmt chunk (16-byte body).
    putTag(chunks, "fmt ");
    putU32(chunks, 16);
    putU16(chunks, fmtTag);            // format tag
    putU16(chunks, channels);
    putU32(chunks, sampleRate);
    const std::uint32_t byteRate = sampleRate * channels * (bits / 8);
    putU32(chunks, byteRate);
    putU16(chunks, static_cast<std::uint16_t>(channels * (bits / 8)));  // block align
    putU16(chunks, bits);
    // data chunk.
    putTag(chunks, "data");
    putU32(chunks, dataBytes);
    for (std::size_t f = 0; f < frames; ++f)
        for (std::uint16_t c = 0; c < channels; ++c)
            putFloat(chunks, value(f, c));

    std::vector<std::uint8_t> wav;
    putTag(wav, "RIFF");
    putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));  // "WAVE" + chunks
    putTag(wav, "WAVE");
    wav.insert(wav.end(), chunks.begin(), chunks.end());
    return wav;
}

// Builds a WAVE_FORMAT_EXTENSIBLE (0xFFFE) WAV with a 40-byte fmt body.
// `subFormatTag` is the 2-byte leading tag embedded in the SubFormat GUID:
//   0x0003 = IEEE float, 0x0001 = PCM integer (and any other value to exercise rejection).
// bitsPerSample and the PCM data are always 32-bit float bytes regardless of subFormatTag
// (we're testing that the parser correctly rejects/accepts based on the GUID, not the data).
template <typename Fn>
static std::vector<std::uint8_t> buildExtensibleWav(std::uint16_t channels,
                                                     std::uint32_t sampleRate,
                                                     std::size_t frames,
                                                     Fn value,
                                                     std::uint16_t subFormatTag) {
    const std::uint32_t dataBytes =
        static_cast<std::uint32_t>(frames * channels * 4u);

    // WAVEFORMATEXTENSIBLE fmt body (40 bytes):
    //   [0..1]   wFormatTag      = 0xFFFE
    //   [2..3]   nChannels
    //   [4..7]   nSamplesPerSec
    //   [8..11]  nAvgBytesPerSec
    //   [12..13] nBlockAlign
    //   [14..15] wBitsPerSample  = 32
    //   [16..17] cbSize          = 22 (extension size beyond the 18-byte WAVEFORMATEX)
    //   [18..19] wValidBitsPerSample = 32
    //   [20..23] dwChannelMask   = 0
    //   [24..39] SubFormat GUID: first 2 bytes = subFormatTag (LE), rest = standard
    //            KSDATAFORMAT_SUBTYPE base GUID {00000000-0000-0010-8000-00aa00389b71}
    std::vector<std::uint8_t> fmt;
    putU16(fmt, 0xFFFE);                                    // wFormatTag
    putU16(fmt, channels);                                  // nChannels
    putU32(fmt, sampleRate);                                // nSamplesPerSec
    putU32(fmt, sampleRate * channels * 4u);                // nAvgBytesPerSec
    putU16(fmt, static_cast<std::uint16_t>(channels * 4)); // nBlockAlign
    putU16(fmt, 32);                                        // wBitsPerSample
    putU16(fmt, 22);                                        // cbSize
    putU16(fmt, 32);                                        // wValidBitsPerSample
    putU32(fmt, 0);                                         // dwChannelMask
    // SubFormat GUID (16 bytes): [subFormatTag, 0x0000, 0x00, 0x00, 0x10, 0x00,
    //                             0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71]
    putU16(fmt, subFormatTag);   // bytes [24..25]: the effective format tag
    putU16(fmt, 0x0000);         // bytes [26..27]
    fmt.push_back(0x00); fmt.push_back(0x00); // bytes [28..29]
    fmt.push_back(0x10); fmt.push_back(0x00); // bytes [30..31]
    fmt.push_back(0x80); fmt.push_back(0x00); // bytes [32..33]
    fmt.push_back(0x00); fmt.push_back(0xaa); // bytes [34..35]
    fmt.push_back(0x00); fmt.push_back(0x38); // bytes [36..37]
    fmt.push_back(0x9b); fmt.push_back(0x71); // bytes [38..39]

    std::vector<std::uint8_t> chunks;
    putTag(chunks, "fmt ");
    putU32(chunks, static_cast<std::uint32_t>(fmt.size()));  // 40
    chunks.insert(chunks.end(), fmt.begin(), fmt.end());
    putTag(chunks, "data");
    putU32(chunks, dataBytes);
    for (std::size_t f = 0; f < frames; ++f)
        for (std::uint16_t c = 0; c < channels; ++c)
            putFloat(chunks, value(f, c));

    std::vector<std::uint8_t> wav;
    putTag(wav, "RIFF");
    putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
    putTag(wav, "WAVE");
    wav.insert(wav.end(), chunks.begin(), chunks.end());
    return wav;
}

// --- Parse tests -------------------------------------------------------------

static void testParseCanonicalStereo() {
    auto wav = buildFloatWav(2, 48000, 5,
                             [](std::size_t f, std::uint16_t c) {
                                 return static_cast<float>(f) + 0.1f * c;
                             });
    WavLayout L = parseWavLayout(wav);
    CHECK(L.valid);
    CHECK(L.channelCount == 2);
    CHECK(L.sampleRate == 48000);
    CHECK(L.dataByteLength == 5 * 2 * 4);
    CHECK(L.frameCount() == 5);
    // data body sits after RIFF(12) + fmt(8 header + 16 body) + data(8 header) = 44.
    CHECK(L.dataByteOffset == 44);
    CHECK(L.dataSizeFieldOffset == 40);  // the 4 bytes before dataByteOffset
    CHECK(L.riffSizeFieldOffset == 4);
}

static void testParseMonoAndLeadingChunk() {
    // A leading LIST/INFO chunk before fmt must be skipped by the walk.
    auto wav = buildFloatWav(1, 44100, 3,
                             [](std::size_t f, std::uint16_t) {
                                 return static_cast<float>(f);
                             },
                             /*leadingJunk=*/true);
    WavLayout L = parseWavLayout(wav);
    CHECK(L.valid);
    CHECK(L.channelCount == 1);
    CHECK(L.frameCount() == 3);
    // Data still parses correctly despite the leading chunk shifting its offset.
    auto pcm = extractFloatFrames(wav, L, 0, 3);
    CHECK(pcm.size() == 3);
    CHECK(pcm[0] == 0.0f && pcm[1] == 1.0f && pcm[2] == 2.0f);
}

static void testParseRejectsNon32BitAndNonWav() {
    // 16-bit PCM (tag 1, bits 16) -> rejected.
    auto pcm16 = buildFloatWav(2, 48000, 4,
                               [](std::size_t, std::uint16_t) { return 0.0f; },
                               false, /*fmtTag=*/1, /*bits=*/16);
    CHECK(!parseWavLayout(pcm16).valid);

    // Not a RIFF file.
    std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
    CHECK(!parseWavLayout(junk).valid);

    // Too short to hold even the RIFF header.
    std::vector<std::uint8_t> tiny = {'R','I','F','F'};
    CHECK(!parseWavLayout(tiny).valid);
}

static void testParseRejectsLyingDataLength() {
    // Build a valid WAV, then inflate the `data` size field so it claims more bytes
    // than the buffer holds -> must be rejected (no OOB trust).
    auto wav = buildFloatWav(2, 48000, 4,
                             [](std::size_t, std::uint16_t) { return 1.0f; });
    WavLayout good = parseWavLayout(wav);
    CHECK(good.valid);
    // Overwrite the data size field with a huge value.
    wav[good.dataSizeFieldOffset + 0] = 0xFF;
    wav[good.dataSizeFieldOffset + 1] = 0xFF;
    wav[good.dataSizeFieldOffset + 2] = 0xFF;
    wav[good.dataSizeFieldOffset + 3] = 0x7F;
    CHECK(!parseWavLayout(wav).valid);
}

// --- Extraction tests --------------------------------------------------------

static void testExtractTailWindow() {
    // Stereo, 10 frames. Sample value encodes frame+channel so a mis-index is caught.
    auto wav = buildFloatWav(2, 48000, 10,
                             [](std::size_t f, std::uint16_t c) {
                                 return static_cast<float>(f) * 10.0f + c;
                             });
    WavLayout L = parseWavLayout(wav);
    CHECK(L.valid);

    // The "tail region" the realtime trim scans: frames 6..9 (start at frame 6).
    auto tail = extractFloatFrames(wav, L, 6, 100 /*clamps*/);
    CHECK(tail.size() == 4 * 2);           // frames 6,7,8,9, 2 channels each
    CHECK(tail[0] == 60.0f && tail[1] == 61.0f);  // frame 6: L=60,R=61
    CHECK(tail[6] == 90.0f && tail[7] == 91.0f);  // frame 9: L=90,R=91

    // Out-of-range start -> empty.
    CHECK(extractFloatFrames(wav, L, 10, 4).empty());
    CHECK(extractFloatFrames(wav, L, 99, 4).empty());
}

// --- Truncate-plan tests -----------------------------------------------------

static void testTruncatePlanKeepFewer() {
    auto wav = buildFloatWav(2, 48000, 10,
                             [](std::size_t, std::uint16_t) { return 0.0f; });
    WavLayout L = parseWavLayout(wav);
    CHECK(L.valid);

    // Keep 4 of 10 frames.
    WavTruncatePlan p = planWavTruncate(L, 4);
    CHECK(p.valid);
    const std::size_t bpf = 2 * 4;                 // channels * 4 bytes
    CHECK(p.newDataSize == 4 * bpf);               // 32 bytes of PCM kept
    CHECK(p.newFileByteLength == L.dataByteOffset + 4 * bpf);  // 44 + 32 = 76
    CHECK(p.newRiffSize == p.newFileByteLength - 8);
    CHECK(p.dataSizeFieldOffset == L.dataSizeFieldOffset);
    CHECK(p.riffSizeFieldOffset == 4);

    // Applying the plan yields a buffer that re-parses to exactly 4 frames.
    std::vector<std::uint8_t> trimmed(wav.begin(),
                                      wav.begin() + p.newFileByteLength);
    // Patch the two size fields with the module's own patch primitive (what the
    // shell does before truncating on disk).
    patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize);
    patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize);

    WavLayout L2 = parseWavLayout(trimmed);
    CHECK(L2.valid);
    CHECK(L2.frameCount() == 4);
    CHECK(L2.dataByteLength == 4 * bpf);
}

static void testTruncatePlanKeepAllIsNoOp() {
    auto wav = buildFloatWav(1, 48000, 6,
                             [](std::size_t, std::uint16_t) { return 0.0f; });
    WavLayout L = parseWavLayout(wav);
    WavTruncatePlan p = planWavTruncate(L, 6);  // keep all
    CHECK(p.valid);
    CHECK(p.newFileByteLength == wav.size());   // unchanged
    CHECK(p.newDataSize == L.dataByteLength);
}

// --- Extensible format tests -------------------------------------------------

// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0001 (PCM integer) and
// bitsPerSample==32 must be REJECTED — it is 32-bit integer, not 32-bit float.
static void testExtensiblePcmIntegerRejected() {
    auto wav = buildExtensibleWav(2, 48000, 4,
                                  [](std::size_t, std::uint16_t) { return 0.0f; },
                                  /*subFormatTag=*/0x0001);  // PCM integer
    CHECK(!parseWavLayout(wav).valid);
}

// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0003 (IEEE float) and
// bitsPerSample==32 must be ACCEPTED and parse + trim correctly.
static void testExtensibleFloatAccepted() {
    auto wav = buildExtensibleWav(2, 48000, 5,
                                  [](std::size_t f, std::uint16_t c) {
                                      return static_cast<float>(f) + 0.1f * c;
                                  },
                                  /*subFormatTag=*/0x0003);  // IEEE float
    WavLayout L = parseWavLayout(wav);
    CHECK(L.valid);
    CHECK(L.channelCount == 2);
    CHECK(L.sampleRate == 48000);
    CHECK(L.frameCount() == 5);

    // Frame extraction works correctly.
    auto pcm = extractFloatFrames(wav, L, 0, 2);
    CHECK(pcm.size() == 4);
    CHECK(pcm[0] == 0.0f);   // frame 0, channel 0
    CHECK(pcm[1] == 0.1f);   // frame 0, channel 1

    // Truncate plan is valid and re-parses cleanly.
    WavTruncatePlan p = planWavTruncate(L, 3);
    CHECK(p.valid);
    CHECK(p.newDataSize == 3 * 2 * 4u);
    std::vector<std::uint8_t> trimmed(wav.begin(), wav.begin() + p.newFileByteLength);
    patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize);
    patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
    WavLayout L2 = parseWavLayout(trimmed);
    CHECK(L2.valid);
    CHECK(L2.frameCount() == 3);
}

static void testTruncatePlanKeepZeroAndGrowRejected() {
    auto wav = buildFloatWav(2, 48000, 5,
                             [](std::size_t, std::uint16_t) { return 0.0f; });
    WavLayout L = parseWavLayout(wav);

    WavTruncatePlan zero = planWavTruncate(L, 0);
    CHECK(zero.valid);
    CHECK(zero.newDataSize == 0);
    CHECK(zero.newFileByteLength == L.dataByteOffset);  // header only

    // keptFrames > total -> refused (never grow a file).
    CHECK(!planWavTruncate(L, 6).valid);

    // Invalid layout -> invalid plan.
    WavLayout bad;
    CHECK(!planWavTruncate(bad, 0).valid);
}

// --- patchU32LE (the size-field patch primitive) ------------------------------

static void testPatchU32LEWritesLittleEndian() {
    std::vector<std::uint8_t> buf(8, 0xEE);
    patchU32LE(buf, 2, 0x0A0B0C0Du);
    CHECK(buf[0] == 0xEE && buf[1] == 0xEE);   // bytes outside the field untouched
    CHECK(buf[2] == 0x0D && buf[3] == 0x0C && buf[4] == 0x0B && buf[5] == 0x0A);
    CHECK(buf[6] == 0xEE && buf[7] == 0xEE);
}

// --- buildFloat32Wav (the one WAV writer, absorbed from ingest — T4-10) -------

static void testBuildFloat32WavGoldenHeaderAndRoundTrip() {
    // 2 channels, 3 frames of known interleaved values.
    const std::vector<double> pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125};
    auto wav = buildFloat32Wav(2, 48000, 3, pcm);

    // Golden container shape: 44-byte header + 6 samples * 4 bytes.
    CHECK(wav.size() == 44u + 6u * 4u);
    CHECK(std::memcmp(wav.data(), "RIFF", 4) == 0);
    CHECK(std::memcmp(wav.data() + 8, "WAVE", 4) == 0);
    CHECK(std::memcmp(wav.data() + 12, "fmt ", 4) == 0);
    CHECK(std::memcmp(wav.data() + 36, "data", 4) == 0);
    CHECK(wav[20] == 0x03 && wav[21] == 0x00);  // WAVE_FORMAT_IEEE_FLOAT
    CHECK(wav[34] == 32 && wav[35] == 0);       // bitsPerSample = 32

    // The build round-trips through the module's own parse + extraction, with the
    // documented double->float narrowing.
    WavLayout L = parseWavLayout(wav);
    CHECK(L.valid);
    CHECK(L.channelCount == 2);
    CHECK(L.sampleRate == 48000);
    CHECK(L.frameCount() == 3);
    auto back = extractFloatFrames(wav, L, 0, 3);
    CHECK(back.size() == 6);
    for (std::size_t i = 0; i < back.size(); ++i)
        CHECK(back[i] == static_cast<float>(pcm[i]));
}

static void testBuildFloat32WavShortInputRejectedByParse() {
    // Fewer interleaved samples than frameCount*nch declares: the data chunk still
    // declares the full length; the missing tail simply is not written. The build
    // caller (ingest) always passes a full buffer; this locks the clamp-no-OOB shape.
    const std::vector<double> pcm = {1.0};  // 1 sample for a 2-frame mono request
    auto wav = buildFloat32Wav(1, 44100, 2, pcm);
    // Declared data size covers 2 frames; actual bytes stop after 1 sample, so the
    // declared length overruns the buffer -> parse rejects (the honest verdict for
    // a short-fed build; ingest never produces this).
    CHECK(wav.size() == 44u + 4u);
    CHECK(!parseWavLayout(wav).valid);
}

// --- hashBytes (FNV-1a content hash — moved with the impl from capture_paths) --

static void testHashBytesOutputFormat() {
    // Output is always 16 lowercase hex characters.
    const std::uint8_t bytes[] = {0x01, 0x02, 0x03};
    const std::string h = hashBytes(bytes, 3);
    CHECK(h.size() == 16);
    for (char c : h) {
        CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
    }
}

static void testHashBytesDeterministicAndDistinct() {
    // Same input always produces the same output; different inputs differ (no
    // accidental dedup of distinct files), including a one-bit flip and a
    // trailing-zero-byte length change.
    const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01};
    CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5));

    std::uint8_t a[] = {0x00, 0x00};
    std::uint8_t b[] = {0x00, 0x01};
    CHECK(hashBytes(a, 2) != hashBytes(b, 2));

    const std::uint8_t short_buf[] = {0xAB, 0xCD};
    const std::uint8_t long_buf[]  = {0xAB, 0xCD, 0x00};
    CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3));
}

static void testHashBytesEmptyBufferIsNonEmpty() {
    // An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty
    // sentinel) — capturing the contract that even empty inputs yield a 16-char hash.
    const std::string h = hashBytes(nullptr, 0);
    CHECK(h.size() == 16);
}

// --- hashWavContent (WAV-aware dedup hash) -----------------------------------
//
// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata
// chunks like bext/LIST), falls back gracefully for non-WAV input, and that
// different audio data yields different hashes.

// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk
// inserted between "WAVE" and the fmt chunk. This is the shape REAPER produces: a
// `bext` or `LIST` chunk before fmt with a render-time timestamp in the body.
static std::vector<std::uint8_t> buildMetaWav(
        std::uint16_t channels, std::uint32_t sampleRate,
        const std::vector<float>& samples,
        bool insertMeta = false,
        const char* metaTag = "bext",
        const std::vector<std::uint8_t>& metaBody = {}) {

    std::vector<std::uint8_t> chunks;

    if (insertMeta && !metaBody.empty()) {
        putTag(chunks, metaTag);
        putU32(chunks, static_cast<std::uint32_t>(metaBody.size()));
        chunks.insert(chunks.end(), metaBody.begin(), metaBody.end());
        if (metaBody.size() & 1u) chunks.push_back(0);  // RIFF pad
    }

    // fmt chunk (16-byte body, IEEE-float tag 3).
    const std::uint32_t dataBytes =
        static_cast<std::uint32_t>(samples.size() * 4u);
    putTag(chunks, "fmt ");
    putU32(chunks, 16);
    putU16(chunks, 3);                                            // IEEE float
    putU16(chunks, channels);
    putU32(chunks, sampleRate);
    putU32(chunks, sampleRate * channels * 4u);                   // byteRate
    putU16(chunks, static_cast<std::uint16_t>(channels * 4));    // blockAlign
    putU16(chunks, 32);                                           // bitsPerSample

    // data chunk.
    putTag(chunks, "data");
    putU32(chunks, dataBytes);
    for (float f : samples) putFloat(chunks, f);

    std::vector<std::uint8_t> wav;
    putTag(wav, "RIFF");
    putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
    putTag(wav, "WAVE");
    wav.insert(wav.end(), chunks.begin(), chunks.end());
    return wav;
}

static void testHashWavContentIdenticalAudioSameHash() {
    // Two WAVs with the same audio but different metadata body -> same hash.
    // This is the core dedup regression: REAPER embeds a bext chunk with a
    // render-time origination timestamp; without WAV-aware hashing, two renders
    // of the same clip produce different file bytes -> no dedup collapse.
    const std::vector<float> audio = {0.1f, -0.2f, 0.3f, -0.4f};
    std::vector<std::uint8_t> metaA(64, 0x00);  // bext body, all zeros (e.g. epoch)
    std::vector<std::uint8_t> metaB(64, 0x00);
    // Different origination timestamps: first 10 bytes of bext are ASCII date/time.
    metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6';  // year

    auto wavA = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaA);
    auto wavB = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaB);

    // Files must differ (the bext body is different) to prove the test is valid.
    CHECK(wavA != wavB);

    // But their content hashes must be equal: same fmt+data, different metadata.
    CHECK(hashWavContent(wavA) == hashWavContent(wavB));
}

static void testHashWavContentDifferentAudioDifferentHash() {
    // Different PCM data -> different content hashes (no false dedup).
    const std::vector<float> audioA = {0.5f, 0.5f};
    const std::vector<float> audioB = {0.5f, 0.6f};  // last sample differs

    auto wavA = buildMetaWav(1, 44100, audioA);
    auto wavB = buildMetaWav(1, 44100, audioB);

    CHECK(hashWavContent(wavA) != hashWavContent(wavB));
}

static void testHashWavContentDifferentFmtDifferentHash() {
    // Different fmt fields (sample rate) -> different content hashes.
    const std::vector<float> audio = {0.1f, 0.2f};
    auto wav44 = buildMetaWav(1, 44100, audio);
    auto wav48 = buildMetaWav(1, 48000, audio);
    CHECK(hashWavContent(wav44) != hashWavContent(wav48));
}

static void testHashWavContentNonWavFallsBackToWholeFile() {
    // Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty
    // and equals hashBytes of the same bytes directly. Empty input likewise.
    std::vector<std::uint8_t> notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02};
    const std::string h = hashWavContent(notWav);
    CHECK(!h.empty());
    CHECK(h.size() == 16);
    CHECK(h == hashBytes(notWav.data(), notWav.size()));

    std::vector<std::uint8_t> empty;
    const std::string he = hashWavContent(empty);
    CHECK(he.size() == 16);
    CHECK(he == hashBytes(nullptr, 0));
}

static void testHashWavContentListMetaSkipped() {
    // A LIST/INFO chunk (another common metadata chunk) is likewise skipped.
    const std::vector<float> audio = {1.0f, -1.0f, 0.5f};
    std::vector<std::uint8_t> listBody = {'I','N','F','O', 'x','x','x','x'};
    auto wavClean = buildMetaWav(1, 48000, audio);
    auto wavList  = buildMetaWav(1, 48000, audio, true, "LIST", listBody);

    // Content hashes must match: only the LIST chunk differs.
    CHECK(hashWavContent(wavClean) == hashWavContent(wavList));
}

static void testHashWavContentDomainSeparationFromWholeFile() {
    // The content hash ('W'-prefixed) must not accidentally equal the whole-file
    // hash of the SAME bytes. This guards against the domain-separation prefix
    // being dropped or zeroed out.
    const std::vector<float> audio = {0.0f};
    auto wav = buildMetaWav(1, 44100, audio);
    const std::string contentHash  = hashWavContent(wav);
    const std::string wholeHash    = hashBytes(wav.data(), wav.size());
    CHECK(contentHash != wholeHash);
}

static void testHashMatchesBuildOutput() {
    // The consolidation guarantee end-to-end: a WAV produced by the module's own
    // builder hashes as WAV content (not the whole-file fallback), so an imported
    // conversion and a captured render of identical audio can dedup-collapse.
    const std::vector<double> pcm = {0.25, -0.25};
    auto wav = buildFloat32Wav(1, 44100, 2, pcm);
    CHECK(hashWavContent(wav) != hashBytes(wav.data(), wav.size()));  // chunk-aware path taken
    // And a metadata-bearing copy of the same audio content hashes identically.
    auto withMeta = buildMetaWav(1, 44100, {0.25f, -0.25f}, true, "bext",
                                 std::vector<std::uint8_t>(16, 0x7A));
    CHECK(hashWavContent(wav) == hashWavContent(withMeta));
}

// Golden hash literal (review follow-up, Q-W3): the tests above prove format,
// determinism, and relational properties (equal/different) of hashBytes and
// hashWavContent, but none of them pins the actual output bytes — a feed-sequence
// change (e.g. dropping the 'W' domain-separation prefix, or reordering the
// fmt/data feed) would pass every test above while silently invalidating every
// contentHash already persisted in existing projects' BankIndex JSON. This test
// locks a fixed buildFloat32Wav input's hashes against exact hex literals
// captured from the current implementation, so any such drift fails loudly here.
static void testGoldenHashLiterals() {
    const std::vector<double> pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125};
    auto wav = buildFloat32Wav(2, 48000, 3, pcm);
    CHECK(hashWavContent(wav) == "7ccf298c166a670a");
    CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44");
}

// --- Lossless mono collapse --------------------------------------------------

// Every channel carries frame f's value; the collapse must keep those values verbatim
// in one channel and leave frame count / rate / bit depth alone.
static void testCollapseBitIdenticalStereo() {
    auto wav = buildFloatWav(2, 48000, 6,
                             [](std::size_t f, std::uint16_t) {
                                 return 0.25f * static_cast<float>(f) - 0.5f;
                             });
    const MonoCollapse c = collapseToMono(wav);
    CHECK(c.collapsed);

    const WavLayout L = parseWavLayout(c.bytes);
    CHECK(L.valid);                  // valid implies float32: the parser rejects anything else
    CHECK(L.channelCount == 1);
    CHECK(L.sampleRate == 48000);
    CHECK(L.frameCount() == 6);

    const auto pcm = extractFloatFrames(c.bytes, L, 0, 6);
    CHECK(pcm.size() == 6);
    for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
        CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
}

static void testCollapseDeclinesOnOneDifferingSample() {
    // Identical everywhere except frame 4's right channel, by the smallest step the
    // format can express near 1.0.
    auto wav = buildFloatWav(2, 48000, 8,
                             [](std::size_t f, std::uint16_t ch) {
                                 float v = 1.0f + static_cast<float>(f);
                                 if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
                                 return v;
                             });
    CHECK(!collapseToMono(wav).collapsed);
    CHECK(collapseToMono(wav).bytes.empty());
}

// An already-mono file must come back untouched — a second capture pass over a
// collapsed file must not rebuild (and so must not re-hash) it.
static void testCollapseDeclinesOnAlreadyMono() {
    auto wav = buildFloatWav(1, 44100, 4,
                             [](std::size_t f, std::uint16_t) {
                                 return static_cast<float>(f);
                             });
    CHECK(!collapseToMono(wav).collapsed);
}

// N-channel generalization: all-identical collapses to ONE channel, never a partial
// fold (4 -> 2). Unreachable from today's capture paths, which always render 2.
static void testCollapseFourChannels() {
    auto same = buildFloatWav(4, 48000, 5,
                              [](std::size_t f, std::uint16_t) {
                                  return -0.125f * static_cast<float>(f);
                              });
    const MonoCollapse c = collapseToMono(same);
    CHECK(c.collapsed);
    const WavLayout L = parseWavLayout(c.bytes);
    CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 5);

    auto oneDiffers = buildFloatWav(4, 48000, 5,
                                    [](std::size_t f, std::uint16_t ch) {
                                        float v = -0.125f * static_cast<float>(f);
                                        if (f == 2 && ch == 3) v += 0.5f;
                                        return v;
                                    });
    CHECK(!collapseToMono(oneDiffers).collapsed);
}

static void testCollapseZeroAndSingleFrame() {
    // No frame of evidence that the channels agree -> decline rather than rebuild.
    auto empty = buildFloatWav(2, 48000, 0,
                               [](std::size_t, std::uint16_t) { return 0.0f; });
    CHECK(parseWavLayout(empty).valid && parseWavLayout(empty).frameCount() == 0);
    CHECK(!collapseToMono(empty).collapsed);

    auto one = buildFloatWav(2, 48000, 1,
                             [](std::size_t, std::uint16_t) { return 0.75f; });
    const MonoCollapse c = collapseToMono(one);
    CHECK(c.collapsed);
    const WavLayout L = parseWavLayout(c.bytes);
    CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
    const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
    CHECK(pcm.size() == 1 && pcm[0] == 0.75f);
}

// The predicate is over BIT PATTERNS: -0.0f == +0.0f compares equal as floats but is
// a different value on disk, so folding it would not be lossless.
static void testCollapseSignedZeroIsNotIdentical() {
    auto wav = buildFloatWav(2, 48000, 3,
                             [](std::size_t, std::uint16_t ch) {
                                 return ch == 0 ? 0.0f : -0.0f;
                             });
    CHECK(!collapseToMono(wav).collapsed);
}

// A leading odd-sized chunk exercises the walk's RIFF pad byte; the rebuilt file is
// canonical, so that chunk does not survive.
static void testCollapseThroughOddPaddedLeadingChunk() {
    std::vector<std::uint8_t> chunks;
    putTag(chunks, "LIST");
    putU32(chunks, 5);                       // odd body -> one pad byte
    for (int i = 0; i < 5; ++i) chunks.push_back(0x41);
    chunks.push_back(0);                     // the pad
    putTag(chunks, "fmt ");
    putU32(chunks, 16);
    putU16(chunks, 3);
    putU16(chunks, 2);
    putU32(chunks, 48000);
    putU32(chunks, 48000u * 2u * 4u);
    putU16(chunks, 8);
    putU16(chunks, 32);
    putTag(chunks, "data");
    putU32(chunks, 3u * 2u * 4u);
    for (std::size_t f = 0; f < 3; ++f)
        for (int ch = 0; ch < 2; ++ch) putFloat(chunks, 0.5f * static_cast<float>(f));

    std::vector<std::uint8_t> wav;
    putTag(wav, "RIFF");
    putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
    putTag(wav, "WAVE");
    wav.insert(wav.end(), chunks.begin(), chunks.end());

    const MonoCollapse c = collapseToMono(wav);
    CHECK(c.collapsed);
    const WavLayout L = parseWavLayout(c.bytes);
    CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 3);
    // Canonical rebuild: byte-for-byte what buildFloat32Wav produces for the same PCM.
    CHECK(c.bytes == buildFloat32Wav(1, 48000, 3, {0.0, 0.5, 1.0}));
}

static void testCollapseDeclinesOnUnparseableBytes() {
    std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
    CHECK(!collapseToMono(junk).collapsed);
    CHECK(!collapseToMono(std::vector<std::uint8_t>{}).collapsed);
}

// Stated consequence, pinned: the collapse rewrites both the `fmt ` body and the
// `data` payload, so a collapsed capture no longer shares content identity with the
// stereo file it came from and will not dedup against one already in the bank.
static void testCollapseChangesContentHash() {
    auto wav = buildFloatWav(2, 48000, 4,
                             [](std::size_t f, std::uint16_t) {
                                 return static_cast<float>(f);
                             });
    const MonoCollapse c = collapseToMono(wav);
    CHECK(c.collapsed);
    CHECK(hashWavContent(c.bytes) != hashWavContent(wav));
}

// The float->double->float rebuild's stated hole is a SIGNALING NaN (double promotion
// quiets it); a QUIET NaN is not that hole. Both channels carry the identical
// quiet-NaN bit pattern, so the predicate collapses; the rebuilt mono channel must
// carry that exact bit pattern back, not merely "some NaN".
static void testCollapsePreservesQuietNaNBitPattern() {
    constexpr std::uint32_t kQuietNaNBits = 0x7FC12345u;  // exponent all-ones, mantissa MSB set
    auto wav = buildFloatWav(2, 48000, 1,
                             [kQuietNaNBits](std::size_t, std::uint16_t) {
                                 return floatFromBits(kQuietNaNBits);
                             });
    const MonoCollapse c = collapseToMono(wav);
    CHECK(c.collapsed);
    const WavLayout L = parseWavLayout(c.bytes);
    CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
    const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
    CHECK(pcm.size() == 1);
    if (!pcm.empty()) CHECK(bitsFromFloat(pcm[0]) == kQuietNaNBits);
}

// The file-side collapse has three outcomes, and a genuine I/O failure once reported
// identically to "the channels differ" — a stereo file landing under a plain Ok. The
// report is where they must part: Declined stays silent (a capture with nothing to
// collapse reads as it always did), and the other two say different things.
// Pins the three suffix STRINGS, not user-observable behavior — see wav_codec.h's
// monoCollapseSuffix doc: this text reaches no one on a successful capture.
static void testCollapseOutcomeSuffixesAreDistinctStrings() {
    const std::string declined  = monoCollapseSuffix(MonoCollapseOutcome::Declined);
    const std::string collapsed = monoCollapseSuffix(MonoCollapseOutcome::Collapsed);
    const std::string failed    = monoCollapseSuffix(MonoCollapseOutcome::Failed);

    CHECK(declined.empty());
    CHECK(!collapsed.empty());
    CHECK(!failed.empty());
    CHECK(collapsed != failed);
    // The failure must read as a failure, not as a quieter success.
    CHECK(failed.find("failed") != std::string::npos);
    CHECK(collapsed.find("failed") == std::string::npos);
}

// --- applyMonoCollapse: the buffer-side collapse a bake landing takes ---------

// A dead-center instrument render is dual-mono, and must land 1-channel: the returned
// layout says one channel, and it is the parse OF the returned bytes, so the caller's
// channelCount, its hash and the file it writes cannot come from different buffers.
static void testApplyCollapseDualMonoLandsOneChannel() {
    auto wav = buildFloatWav(2, 48000, 6,
                             [](std::size_t f, std::uint16_t) {
                                 return 0.25f * static_cast<float>(f) - 0.5f;
                             });
    const CollapsedWav staged = applyMonoCollapse(wav);
    CHECK(staged.collapsed);
    CHECK(staged.layout.valid);
    CHECK(staged.layout.channelCount == 1);

    const WavLayout reparsed = parseWavLayout(staged.bytes);
    CHECK(reparsed.valid);
    CHECK(reparsed.channelCount == staged.layout.channelCount);
    CHECK(reparsed.sampleRate == staged.layout.sampleRate);
    CHECK(reparsed.frameCount() == staged.layout.frameCount());
    CHECK(reparsed.dataByteOffset == staged.layout.dataByteOffset);
    CHECK(reparsed.dataByteLength == staged.layout.dataByteLength);

    const auto pcm = extractFloatFrames(staged.bytes, staged.layout, 0, 6);
    CHECK(pcm.size() == 6);
    for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
        CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
}

// A true-stereo bake must land exactly the bytes it staged — this is the regression the
// collapse must not cause, so it is asserted on the bytes themselves, not on the verdict.
static void testApplyCollapseTrueStereoIsByteIdentical() {
    auto wav = buildFloatWav(2, 48000, 5,
                             [](std::size_t f, std::uint16_t ch) {
                                 return ch == 0 ? static_cast<float>(f)
                                                : -static_cast<float>(f);
                             });
    const CollapsedWav staged = applyMonoCollapse(wav);
    CHECK(!staged.collapsed);
    CHECK(staged.bytes == wav);
    CHECK(staged.layout.channelCount == 2);
    CHECK(staged.layout.frameCount() == 5);
    // The dedup key a declined bake writes is the one it would have written before the
    // collapse existed.
    CHECK(hashWavContent(staged.bytes) == hashWavContent(wav));
}

// One float ULP apart in ONE sample is a difference, not an epsilon: the buffer path
// must decline it exactly as the predicate does, and hand the bytes back untouched.
static void testApplyCollapseDeclinesOnOneUlpDifference() {
    auto wav = buildFloatWav(2, 48000, 8,
                             [](std::size_t f, std::uint16_t ch) {
                                 float v = 1.0f + static_cast<float>(f);
                                 if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
                                 return v;
                             });
    const CollapsedWav staged = applyMonoCollapse(wav);
    CHECK(!staged.collapsed);
    CHECK(staged.bytes == wav);
    CHECK(staged.layout.channelCount == 2);
}

// A sound that was already mono comes back untouched, and a collapsed buffer fed back
// through does not collapse a second time (the rebuild would otherwise re-hash).
static void testApplyCollapseAlreadyMonoIsUntouched() {
    auto mono = buildFloatWav(1, 44100, 4,
                              [](std::size_t f, std::uint16_t) {
                                  return static_cast<float>(f);
                              });
    const CollapsedWav staged = applyMonoCollapse(mono);
    CHECK(!staged.collapsed);
    CHECK(staged.bytes == mono);
    CHECK(staged.layout.channelCount == 1);

    auto dual = buildFloatWav(2, 44100, 4,
                              [](std::size_t f, std::uint16_t) {
                                  return static_cast<float>(f);
                              });
    const CollapsedWav once = applyMonoCollapse(dual);
    CHECK(once.collapsed);
    const CollapsedWav twice = applyMonoCollapse(once.bytes);
    CHECK(!twice.collapsed);
    CHECK(twice.bytes == once.bytes);
}

// The collapse is permitted only because it is lossless: frame count, sample rate and
// bit depth survive it, and only the interleave stride changes.
static void testApplyCollapsePreservesFramesRateAndDepth() {
    auto wav = buildFloatWav(2, 44100, 7,
                             [](std::size_t f, std::uint16_t) {
                                 return 0.5f - 0.125f * static_cast<float>(f);
                             });
    const WavLayout before = parseWavLayout(wav);
    const CollapsedWav staged = applyMonoCollapse(wav);
    CHECK(staged.collapsed);
    CHECK(staged.layout.frameCount() == before.frameCount());
    CHECK(staged.layout.sampleRate == before.sampleRate);
    // `valid` implies 32-bit float (the parser accepts nothing else), and 4 bytes per
    // frame at one channel is that depth spelled out in the data chunk's own length.
    CHECK(staged.layout.valid);
    CHECK(staged.layout.dataByteLength == before.frameCount() * 4u);
}

// Bytes that never parsed keep `collapsed` false AND `layout.valid` false — the pair a
// caller refuses on, and the reason an invalid layout can only mean a bad INPUT.
static void testApplyCollapseUnparseableInputIsReportedInvalid() {
    std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
    const CollapsedWav staged = applyMonoCollapse(junk);
    CHECK(!staged.collapsed);
    CHECK(!staged.layout.valid);
    CHECK(staged.bytes == junk);
}

int main() {
    testParseCanonicalStereo();
    testParseMonoAndLeadingChunk();
    testParseRejectsNon32BitAndNonWav();
    testParseRejectsLyingDataLength();
    testExtractTailWindow();
    testTruncatePlanKeepFewer();
    testTruncatePlanKeepAllIsNoOp();
    testTruncatePlanKeepZeroAndGrowRejected();
    testExtensiblePcmIntegerRejected();
    testExtensibleFloatAccepted();
    testPatchU32LEWritesLittleEndian();
    testBuildFloat32WavGoldenHeaderAndRoundTrip();
    testBuildFloat32WavShortInputRejectedByParse();
    testHashBytesOutputFormat();
    testHashBytesDeterministicAndDistinct();
    testHashBytesEmptyBufferIsNonEmpty();
    testHashWavContentIdenticalAudioSameHash();
    testHashWavContentDifferentAudioDifferentHash();
    testHashWavContentDifferentFmtDifferentHash();
    testHashWavContentNonWavFallsBackToWholeFile();
    testHashWavContentListMetaSkipped();
    testHashWavContentDomainSeparationFromWholeFile();
    testHashMatchesBuildOutput();
    testGoldenHashLiterals();
    testCollapseBitIdenticalStereo();
    testCollapseDeclinesOnOneDifferingSample();
    testCollapseDeclinesOnAlreadyMono();
    testCollapseFourChannels();
    testCollapseZeroAndSingleFrame();
    testCollapseSignedZeroIsNotIdentical();
    testCollapseThroughOddPaddedLeadingChunk();
    testCollapseDeclinesOnUnparseableBytes();
    testCollapseChangesContentHash();
    testCollapsePreservesQuietNaNBitPattern();
    testCollapseOutcomeSuffixesAreDistinctStrings();
    testApplyCollapseDualMonoLandsOneChannel();
    testApplyCollapseTrueStereoIsByteIdentical();
    testApplyCollapseDeclinesOnOneUlpDifference();
    testApplyCollapseAlreadyMonoIsUntouched();
    testApplyCollapsePreservesFramesRateAndDepth();
    testApplyCollapseUnparseableInputIsReportedInvalid();

    if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
    else             std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
    return g_fail ? 1 : 0;
}
