// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.

#include "sample_map.h"

#include <algorithm>  // std::min
#include <cassert>    // assert
#include <cstring>    // std::memcpy
#include <utility>    // std::move

namespace reasampler {

namespace {

// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
    SampleLoop out;
    if (s.loop) {
        out.hasLoop = true;
        out.start = s.loop->start;
        out.end = s.loop->end;
    }
    return out;
}

// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) {
    SelectedSample out;
    out.relativePath = s.relativePath;
    out.rootNote = s.rootNote ? *s.rootNote : 60;
    out.loop = loopFromSample(s);
    return out;
}

}  // namespace

std::optional<SelectedSample> selectSample(const std::string& banksJson,
                                           const std::string& sampleId) {
    // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
    // circuit before parsing — no stored id resolves to nothing to play by design.
    if (sampleId.empty()) return std::nullopt;
    if (banksJson.empty()) return std::nullopt;
    std::optional<BankBook> book = BankBook::deserialize(banksJson);
    if (!book) return std::nullopt;  // malformed -> nothing to play (never throw)

    // Search every bank (pool first, then named — banks() is ordinal order) for the
    // stored id. A sample lives in exactly one bank, so first hit wins.
    for (const Bank& b : book->banks()) {
        if (const Sample* s = b.index.query(sampleId)) {
            return distill(*s);
        }
    }
    // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
    // the editor reflects the missing pick with its empty state rather than masking it.
    return std::nullopt;
}

std::vector<SampleChoice> listSamples(const std::string& banksJson) {
    std::vector<SampleChoice> out;
    if (banksJson.empty()) return out;
    std::optional<BankBook> book = BankBook::deserialize(banksJson);
    if (!book) return out;
    for (const Bank& b : book->banks()) {
        for (const Sample& s : b.index.all()) {
            out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
        }
    }
    return out;
}

std::vector<BankChoice> listBanks(const std::string& banksJson) {
    std::vector<BankChoice> out;
    if (banksJson.empty()) return out;
    std::optional<BankBook> book = BankBook::deserialize(banksJson);
    if (!book) return out;
    for (const Bank& b : book->banks()) {
        out.push_back(BankChoice{b.id, b.displayName});
    }
    return out;
}

std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
                                       int channelCount) {
    std::vector<AudioSample> out;
    if (channelCount <= 0 || interleaved.empty()) return out;
    const std::size_t stride = static_cast<std::size_t>(channelCount);
    const std::size_t frames = interleaved.size() / stride;
    out.resize(frames);
    const double inv = 1.0 / static_cast<double>(channelCount);
    for (std::size_t f = 0; f < frames; ++f) {
        double acc = 0.0;
        const std::size_t base = f * stride;
        for (std::size_t c = 0; c < stride; ++c) {
            acc += static_cast<double>(interleaved[base + c]);
        }
        out[f] = static_cast<AudioSample>(acc * inv);
    }
    return out;
}

std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
                                        int channelCount, int which) {
    std::vector<AudioSample> out;
    if (channelCount <= 0 || interleaved.empty()) return out;
    const std::size_t stride = static_cast<std::size_t>(channelCount);
    // Clamp the requested channel into the source's range: a channel past the last one reads
    // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
    std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
    if (ch >= stride) ch = stride - 1;
    const std::size_t frames = interleaved.size() / stride;
    out.resize(frames);
    for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
    return out;
}

DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
                              int sourceChannels, ChannelMode mode, int sampleRate) {
    assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
    DecodedZonePcm out;
    if (sampleRate <= 0) return out;  // safe early-return; caller supplied an invalid rate
    out.sampleRate = sampleRate;
    if (mode == ChannelMode::Mono) {
        // MONO mode: the existing downmix policy (average all source channels), one channel out.
        out.monoFrames = downmixToMono(interleaved, sourceChannels);
        return out;  // framesR stays empty
    }
    // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
    // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
    // out-of-range channel request to the last channel, so a mono source yields L == R.
    out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
    out.framesR = extractChannel(interleaved, sourceChannels, 1);
    return out;
}

ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
    // seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
    // pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
    // through untouched — they are already source frames / fractions. Non-time fields pass as-is.
    assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
    const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0;  // 1.0 avoids div-by-zero; assert fires first
    const auto secToFrames = [sr](double sec) {
        double f = sec * sr;
        if (f < 0.0) f = 0.0;
        return static_cast<std::int64_t>(f + 0.5);
    };
    ZonePlayParams out;
    out.playMode = stored.playMode;
    out.adsr.attackFrames  = secToFrames(stored.adsr.attackSeconds);
    out.adsr.holdFrames    = secToFrames(stored.adsr.holdSeconds);
    out.adsr.decayFrames   = secToFrames(stored.adsr.decaySeconds);
    out.adsr.sustainLevel  = stored.adsr.sustainLevel;   // level, not a time
    out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
    out.trigger = stored.trigger;                        // source-frame / fraction, unchanged
    out.pitchEngine = stored.pitchEngine;
    out.pitchEnv.enabled       = stored.pitchEnv.enabled;
    out.pitchEnv.attackFrames  = secToFrames(stored.pitchEnv.attackSeconds);
    out.pitchEnv.decayFrames   = secToFrames(stored.pitchEnv.decaySeconds);
    out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones;  // depth, not a time
    return out;
}

Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
                        int rootNote, const SampleLoop& loop,
                        std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
    assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
    SampleData data;
    data.frames = std::move(frames);
    // A second channel only counts when it length-matches channel 0 (else the sample stays
    // mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
    if (!framesR.empty() && framesR.size() == data.frames.size()) {
        data.framesR = std::move(framesR);
    }
    if (sampleRate <= 0) return Keymap{};  // safe early-return; assert fires first
    data.sampleRate = sampleRate;
    data.rootNote = rootNote;
    data.loop = loop;
    // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
    data.play = resolvePlay(play, data.sampleRate);

    return Keymap::singleSampleChromatic(std::move(data));
}

// --- Performance map ---------------------------------------------------------

ResolvedPerformance resolvePerformance(const std::string& banksJson,
                                       const PerformanceMap& map) {
    ResolvedPerformance out;
    if (map.zones.empty()) return out;             // empty map -> empty (shell -> Tier 0)
    if (banksJson.empty()) return out;             // no bank -> nothing resolves
    std::optional<BankBook> book = BankBook::deserialize(banksJson);
    if (!book) return out;                         // malformed -> nothing (never throw)

    for (const PerformanceZone& z : map.zones) {
        // Look the id up across every bank (pool + named) — a sample lives in exactly
        // one bank, so first hit wins.
        const Sample* found = nullptr;
        for (const Bank& b : book->banks()) {
            if (const Sample* s = b.index.query(z.sampleId)) {
                found = s;
                break;
            }
        }
        if (!found) {
            // STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
            out.droppedSampleIds.push_back(z.sampleId);
            continue;
        }
        ResolvedZone rz;
        rz.relativePath = found->relativePath;
        rz.lowNote = z.lowNote;
        rz.highNote = z.highNote;
        // Effective root: override beats bank intrinsic beats middle-C default.
        rz.rootNote = z.rootOverride ? *z.rootOverride
                                     : (found->rootNote ? *found->rootNote : 60);
        // S-VIEW-6: the key-tracking scalar is instrument state (not a bank fact) — carried
        // straight through to the resolved zone and applied in the repitch math at play time.
        rz.keyTrack = z.keyTrack;
        // Effective loop / start (S11): the instrument's per-zone override wins over the
        // bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is
        // never mutated — this only shapes what the core plays for THIS instance (D-B).
        rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
        rz.startFrame = z.startPoint ? *z.startPoint : 0;
        // S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
        // state, not resolved against the bank); buildZonedKeymap resolves them to frames.
        rz.play = z.play;
        out.zones.push_back(std::move(rz));
    }
    return out;
}

Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
                        const std::vector<DecodedZonePcm>& decoded) {
    Keymap km;
    const std::size_t n = std::min(zones.size(), decoded.size());
    for (std::size_t i = 0; i < n; ++i) {
        // An unreadable/empty WAV drops just this zone (not the whole map).
        if (decoded[i].monoFrames.empty()) continue;
        SampleData data;
        data.frames = decoded[i].monoFrames;
        // Carry the second channel only when it length-matches channel 0 (channelCount()
        // enforces the same rule; a mismatched pair falls back to mono rather than half-play).
        if (!decoded[i].framesR.empty() &&
            decoded[i].framesR.size() == data.frames.size()) {
            data.framesR = decoded[i].framesR;
        }
        assert(decoded[i].sampleRate > 0 &&
               "buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
        if (decoded[i].sampleRate <= 0) continue;  // safe skip; assert fires first
        data.sampleRate = decoded[i].sampleRate;
        data.rootNote = zones[i].rootNote;
        data.loop = zones[i].loop;
        data.startFrame = zones[i].startFrame;  // S11 effective start (override, else 0)
        // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
        // actual rate; source-timeline params (trigger %-length + fades, start) carry through.
        data.play = resolvePlay(zones[i].play, data.sampleRate);
        const std::size_t sampleIndex = km.samples.size();
        km.samples.push_back(std::move(data));
        KeyZone zone;
        zone.lowNote = zones[i].lowNote;
        zone.highNote = zones[i].highNote;
        zone.rootNote = zones[i].rootNote;
        zone.keyTrack = zones[i].keyTrack;  // S-VIEW-6: applied in keyTrackedRatio at play time
        zone.sampleIndex = sampleIndex;
        km.zones.push_back(zone);
    }
    return km;  // empty zones in -> empty Keymap (silence)
}

// --- Performance-map instance state (setState/getState) -----------------------

namespace {

void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
    out.push_back(static_cast<std::uint8_t>(v & 0xFF));
    out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
    out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
    out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}

// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
// two's-complement u64, mirroring the u32 signed-int idiom above).
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
    for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
}

std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }

// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
std::uint64_t doubleToBits(double d) {
    std::uint64_t bits;
    std::memcpy(&bits, &d, sizeof(bits));
    return bits;
}
double bitsToDouble(std::uint64_t bits) {
    double d;
    std::memcpy(&d, &bits, sizeof(d));
    return d;
}

// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
// blob degrades to a partial/empty parse rather than reading out of bounds.
struct ByteReader {
    const std::vector<std::uint8_t>& bytes;
    std::size_t pos = 0;
    bool ok = true;

    explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}

    std::uint32_t u32() {
        if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
        const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
                                (static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
                                (static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
                                (static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
        pos += 4;
        return v;
    }
    std::uint8_t u8() {
        if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
        return bytes[pos++];
    }
    std::string str(std::uint32_t len) {
        if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
        std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
        pos += len;
        return s;
    }
    // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
    int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }

    std::uint64_t u64() {
        if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
        std::uint64_t v = 0;
        for (int b = 0; b < 8; ++b)
            v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
        pos += 8;
        return v;
    }
    // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
    std::int64_t i64() { return static_cast<std::int64_t>(u64()); }

    // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
    // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
    // as "no marker" and falls through to the (also-guarded) v1 count read.
    std::uint32_t peekU32() const {
        if (!ok || pos + 4 > bytes.size()) return 0;
        return static_cast<std::uint32_t>(bytes[pos]) |
               (static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
               (static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
               (static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
    }
};

// Append the zones payload — the shared body of the performance blob and the component blob,
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
// the zone count so any reader can detect the record shape independently of the envelope version
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
    putU32le(out, kZonesFormatMarker);
    putU32le(out, kZonesPayloadVersion);
    putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
    for (const PerformanceZone& z : map.zones) {
        putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
        out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
        putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
        putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
        out.push_back(z.rootOverride ? 1 : 0);
        if (z.rootOverride) {
            putU32le(out,
                     static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
        }
        // S11 extension: loop override (hasLoop flag + start/end), then start point.
        out.push_back(z.loopOverride ? 1 : 0);
        if (z.loopOverride) {
            out.push_back(z.loopOverride->hasLoop ? 1 : 0);
            putU64le(out, asU64(z.loopOverride->start));
            putU64le(out, asU64(z.loopOverride->end));
        }
        out.push_back(z.startPoint ? 1 : 0);
        if (z.startPoint) putU64le(out, asU64(*z.startPoint));

        // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
        // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
        // fraction. Order matches the header's v5 record spec.
        const ZonePlaySeconds& pp = z.play;
        out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
        putU64le(out, doubleToBits(pp.adsr.holdSeconds));           // wall-clock seconds
        putU64le(out, doubleToBits(pp.trigger.lengthFraction));      // fraction
        putU64le(out, asU64(pp.trigger.fadeInFrames));               // source frames
        putU64le(out, asU64(pp.trigger.fadeOutFrames));              // source frames
        out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
        out.push_back(pp.pitchEnv.enabled ? 1 : 0);
        putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds));      // wall-clock seconds
        putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds));       // wall-clock seconds
        putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones));      // depth
        // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
        putU64le(out, doubleToBits(pp.adsr.attackSeconds));
        putU64le(out, doubleToBits(pp.adsr.decaySeconds));
        putU64le(out, doubleToBits(pp.adsr.sustainLevel));
        putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
        // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar, appended last (1.0 = 100% ET).
        putU64le(out, doubleToBits(z.keyTrack));
    }
}

// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest.
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
    bool extended = false;    // v2+: the S11 loop/start tail is present
    std::uint32_t pv = 0;     // payload version (0 = v1, no marker)
    if (r.peekU32() == kZonesFormatMarker) {
        r.u32();                      // consume the marker
        pv = r.u32();                 // payload version
        extended = (pv >= 2);         // v2+ carries the loop/start tail
    }
    const bool legacyV3Play = (pv == 3);  // legacy S15/S16 play tail, wall-clock in 44.1k frames
    const bool secondsPlay  = (pv >= 5);  // v5+: full play params, wall-clock in seconds
    const bool keyTrackTail = (pv >= 6);  // v6+ (S-VIEW-6): per-zone keyTrack scalar appended last
    const std::uint32_t count = r.u32();
    for (std::uint32_t i = 0; i < count && r.ok; ++i) {
        // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
        // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
        PerformanceZone z;
        const std::uint32_t idLen = r.u32();
        z.sampleId = r.str(idLen);
        z.lowNote = r.i32();
        z.highNote = r.i32();
        const std::uint8_t hasOverride = r.u8();
        if (hasOverride) z.rootOverride = r.i32();
        if (extended) {
            const std::uint8_t hasLoop = r.u8();
            if (hasLoop) {
                SampleLoop lp;
                lp.hasLoop = (r.u8() != 0);
                lp.start = r.i64();
                lp.end = r.i64();
                z.loopOverride = lp;
            }
            const std::uint8_t hasStart = r.u8();
            if (hasStart) z.startPoint = r.i64();
        }
        if (legacyV3Play) {
            // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
            // were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
            // to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
            // A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
            assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
            const double liftRate = projectRate > 0.0 ? projectRate : 1.0;  // 1.0 avoids div-by-zero; assert fires first
            z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
            z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
            z.play.trigger.lengthFraction = bitsToDouble(r.u64());
            z.play.trigger.fadeInFrames = r.i64();
            z.play.trigger.fadeOutFrames = r.i64();
            z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
            z.play.pitchEnv.enabled = (r.u8() != 0);
            z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
            z.play.pitchEnv.decaySeconds  = static_cast<double>(r.i64()) / liftRate;
            z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
        } else if (secondsPlay) {
            // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
            // frames; read in the emit order.
            z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
            z.play.adsr.holdSeconds = bitsToDouble(r.u64());
            z.play.trigger.lengthFraction = bitsToDouble(r.u64());
            z.play.trigger.fadeInFrames = r.i64();
            z.play.trigger.fadeOutFrames = r.i64();
            z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
            z.play.pitchEnv.enabled = (r.u8() != 0);
            z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
            z.play.pitchEnv.decaySeconds  = bitsToDouble(r.u64());
            z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
            z.play.adsr.attackSeconds  = bitsToDouble(r.u64());
            z.play.adsr.decaySeconds   = bitsToDouble(r.u64());
            z.play.adsr.sustainLevel   = bitsToDouble(r.u64());
            z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
        }
        // PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
        // payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
        // already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
        if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
        // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
        // seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
        if (!r.ok) break;  // truncated mid-zone -> keep what parsed cleanly, drop the rest
        map.zones.push_back(std::move(z));
    }
}

}  // namespace

std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
    std::vector<std::uint8_t> out;
    putU32le(out, kPerformanceStateVersion);
    putZonesPayload(out, map);
    return out;
}

PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
                                      double projectRate) {
    // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
    // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
    // blob is encountered with an invalid rate — the calller guarantees a real rate before use.
    PerformanceMap map;
    ByteReader r(bytes);
    const std::uint32_t version = r.u32();
    if (!r.ok) return map;  // no version tag -> empty

    // BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
    // no length prefix). Lift it to one full-keyboard zone playing that id.
    if (version == kSelectionStateVersion) {
        const std::string id = deserializeSelection(bytes);
        if (!id.empty()) {
            PerformanceZone z;
            z.sampleId = id;
            z.lowNote = 0;
            z.highNote = 127;
            map.zones.push_back(std::move(z));
        }
        return map;
    }
    if (version != kPerformanceStateVersion) return map;  // unknown -> empty

    readZonesPayload(r, map, projectRate);
    return map;
}

// --- Combined component state (v3, S10) --------------------------------------

std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
    std::vector<std::uint8_t> out;
    putU32le(out, kComponentStateVersion);
    // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
    out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
    // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
    // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
    // stops at the mode byte is a strict prefix (see the v4 lift below).
    putU64le(out, asU64(state.lastConsumedAssignGeneration));
    // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
    // unlike the v1 selection blob where the id ran to end-of-stream).
    putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
    out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
    putZonesPayload(out, state.map);
    return out;
}

ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
                                         double projectRate) {
    // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
    // For v5 and later blobs it is unused. See readZonesPayload for the guard.
    ComponentState out;
    ByteReader r(bytes);
    const std::uint32_t version = r.u32();
    if (!r.ok) return out;  // no version tag -> empty (the S10 silent empty state)

    // BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
    //   * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
    //     zone} so the old pick survives as BOTH the selection and a one-zone map.
    //   * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
    //     single-capture selection.
    if (version == kSelectionStateVersion) {
        out.selectionId = deserializeSelection(bytes);
        if (!out.selectionId.empty()) {
            PerformanceZone z;
            z.sampleId = out.selectionId;
            z.lowNote = 0;
            z.highNote = 127;
            out.map.zones.push_back(std::move(z));
        }
        return out;
    }
    if (version == kPerformanceStateVersion) {
        readZonesPayload(r, out.map, projectRate);  // v2 body starts right after the version tag
        return out;                                 // channelMode stays Mono (pre-S7)
    }
    // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
    // the id length + id + zones body starts right after the version tag (no mode byte).
    if (version == kSelectionZonesV3Version) {
        const std::uint32_t idLen = r.u32();
        out.selectionId = r.str(idLen);
        if (!r.ok) { out.selectionId.clear(); return out; }  // truncated id -> empty
        readZonesPayload(r, out.map, projectRate);
        return out;                    // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
    }
    // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
    // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
    // defaults to 0, so a first assign still applies for a pre-marker instance.
    if (version == kSelectionZonesModeV4Version) {
        const std::uint8_t modeByte = r.u8();
        if (!r.ok) return out;  // truncated before the mode byte -> empty (mono default holds)
        out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
        const std::uint32_t idLen = r.u32();
        out.selectionId = r.str(idLen);
        if (!r.ok) { out.selectionId.clear(); return out; }  // truncated id -> empty
        readZonesPayload(r, out.map, projectRate);
        return out;                    // marker stays 0 (pre-S8/S9 reader)
    }
    if (version != kComponentStateVersion) return out;  // unknown -> empty

    // v5: the channel-mode byte, then the 8-byte consumed-assignment marker, precede the v3
    // body. A non-{0,1} mode byte is treated as mono (conservative default) rather than
    // rejected — a corrupt mode never silences the instance.
    const std::uint8_t modeByte = r.u8();
    if (!r.ok) return out;  // truncated before the mode byte -> empty (mono default holds)
    out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
    out.lastConsumedAssignGeneration = r.i64();
    if (!r.ok) return out;  // truncated before/inside the marker -> empty (marker 0 holds)
    const std::uint32_t idLen = r.u32();
    out.selectionId = r.str(idLen);
    if (!r.ok) { out.selectionId.clear(); return out; }  // truncated id -> empty
    readZonesPayload(r, out.map, projectRate);
    return out;
}

std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
    std::vector<std::uint8_t> out;
    out.resize(4 + sampleId.size());
    const std::uint32_t v = kSelectionStateVersion;
    out[0] = static_cast<std::uint8_t>(v & 0xFF);
    out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
    out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
    out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
    std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
    return out;
}

std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
    if (bytes.size() < 4) return {};  // no version tag -> no selection
    const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
                            (static_cast<std::uint32_t>(bytes[1]) << 8) |
                            (static_cast<std::uint32_t>(bytes[2]) << 16) |
                            (static_cast<std::uint32_t>(bytes[3]) << 24);
    if (v != kSelectionStateVersion) return {};  // unknown version -> ignore
    return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
                       bytes.size() - 4);
}

}  // namespace reasampler
