#pragma once
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
// instrument's processor reads/writes it at setState/getState, and the extension's
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
// payload and the instrument's reader can never drift — without the extension having to
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v16) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.

#include <cstdint>
#include <string>
#include <vector>

#include "core/instrument/map/sample_map.h"  // InstrumentParams / SampleRefs / SelectedSample

namespace reasampler::instrument::map {

// --- The instance's parameter payload ----------------------------------------
//
// The one parameter set is the instrument's OWN state, serialized to the VST3
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
//
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
// OWN version, so its record can grow without bumping the envelope version. Payload
// extensions and envelope-field additions stay on independent axes that can never collide
// on one version number.
//
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
// case. Their record shapes, in order:
//   * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
//     id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
//     rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
//   * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
//     1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
//     (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
//   * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
//     a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
//     (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
//     LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
//     Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
//     (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
//     v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
//     threaded into the v3 lift path at read time (a parameter, no baked constant).
//     Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
//     tier-0 seconds defaults (0.003/0/1.0/0.060).
//   * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
//     AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
//     trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
//     unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
//     8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
//     adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
//     frames tail) was never shipped and is intentionally not read.
//   * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
//   * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
//     N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
//     payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
//     (soft hits play louder than under the old linear velocity/127 map).
//
// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record
// with no count, no key range and no sample id (the envelope's selection id is the capture):
// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte
// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
// velocity curve (count + points) as in v7.
//
// v9 is v8 PLUS the per-voice filter tail, appended after the velocity curve: 1 byte enabled;
// 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles, widened from the
// module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte LE modAmount,
// velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release SECONDS; then
// the filter's OWN velocity curve (count + points, same shape as v7's). A v8 blob is a strict
// prefix, so it lifts to the off/neutral filter default and plays bit-identically.
//
// v10 is v9 PLUS the staged-curve tail, appended after the filter's velocity curve, all
// 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
// exponents; the Trigger amp AHD (attack SECONDS, decay SECONDS, hold FRACTION, attack curve,
// decay curve); the pitch envelope's hold FRACTION + attack/decay curve exponents; the filter
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
//
// v11 is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE frames (a source-timeline
// quantity like the loop points, so no rate resolves it). A v10-or-older blob is a strict
// prefix and lifts to 0 — the hard seam it always played.
//
// v12 (CURRENT WRITE FORMAT) is v11 PLUS the velocity->PITCH transfer curve (count + points,
// the same shape as v7's), appended after the loop crossfade. Its y is a normalized fraction
// of kVelocityPitchRangeSemitones (play_params.h) — a full-scale constant that lives OUTSIDE
// this frozen ladder, so retuning it re-tunes every saved v12 project's pitch-curve throw. It
// also RE-TAGS the DOMAIN of one frozen slot inside the v9 filter tail: that curve's y is read
// as BIPOLAR [-1,+1] from v12 on, having been UNIPOLAR [0,1] before. Every other filter slot,
// velAmount included, keeps its meaning — the cutoff contribution is still
// velAmount * curve(velocity).
// PRE-v12 LIFT: a domain re-tag and nothing more. A pre-v12 curve's stored y values all lie in
// [0,1], which is inside [-1,+1], so the widened box-clamp alters no knot and eval is unchanged
// at every velocity — a pre-v12 project sounds identical without any rounding argument. A
// pre-v12 blob carries no pitch curve at all and lifts to the bipolar flat-at-zero default,
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// v13 is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
//   (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
//       1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
//       x + 8-byte LE y (doubles) + 1 byte hard. x spans the curve's canonical [0,127] (a
//       normalized-time contour maps onto that same span — velocity_curve.h owns why one span
//       serves both), y is UNIPOLAR [0,1]; the pitch and filter depth knobs scale it.
//   (b) the HARD-FLAG tails for the three v7/v9/v12 velocity curves — amp, filter, pitch, in
//       that order. Each: 4-byte LE count N, then N bytes. Those three curve blocks are FROZEN
//       at 16 bytes/point and cannot grow a per-point flag, so the flags ride here instead. A
//       tail whose count does not match the curve as read is IGNORED (the curve keeps its
//       flags-off default) rather than applied to the wrong knots — a repaired blob loses the
//       hard points, never misplaces them.
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
//
// v14 is v13 PLUS the resample bake's Hold division, appended after the
// hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0
// Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields —
// never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob
// is a strict prefix and lifts to one bar, and Hold reaches no audio path, so a pre-v14
// instance plays and bakes identically except where its window was underived to begin with.
// A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record —
// the same revive discipline the v13 hard-flag tails follow, and for the same reason.
//
// v15 is v14 PLUS ONE byte: the master-bus limiter's enable, appended after the Hold
// division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed,
// which is also the field's product default, so a project saved before the limiter existed
// reopens with the limiter off and sounding identical. It carries the Hold's revive
// discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte
// would otherwise reset the record the Hold's own revive just preserved.
//
// v16 (CURRENT WRITE FORMAT) is v15 PLUS TWO 8-byte LE doubles, appended after the limiter
// byte: the playback RATE as a ratio, then the baseline PITCH offset in semitones. A v15-or-
// older blob is a strict prefix and lifts to 1.0 / 0.0 — unity rate and no offset, which is
// what every instance before them played, so it reopens bit-identical. Both are rate-free
// values, so nothing about them is resolved against the project rate. Same revive-and-drain
// discipline as the two tails above. The two wire GUARDS deliberately differ, and
// readRateAndPitchOffset owns why: the offset is range-checked here because nothing downstream
// bounds it, while the rate is only checked for usability because its range belongs to the
// engine's own clamp.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
// seconds at the project rate the reader is handed. v10+ writes ZERO into both — the values
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
//
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
// seconds, so the lift divides by the PROJECT rate while the build later multiplies by the
// DECODE rate: a file whose own rate differs from the project's comes back scaled by that
// ratio (a 441-frame fade on a 44.1 kHz file in a 48 kHz project resolves to 405 source
// frames, ~8% short). The codec cannot close this — it never sees the file — and deferring the
// lift to build time would mean carrying the retired fade pair through the parameter set,
// reintroducing the mechanism the AHD replaced.
//
// A truncated/unknown/empty payload yields the DEFAULT parameter set.

// The exponents the lifted fades take. The AHD's curve law is phi^p (core/util/curve_law.h),
// which cannot reproduce the retired pair's equal-power sin/cos exactly at ANY exponent — so
// the lift takes the MINIMAX fit rather than the linear neutral, which is free (one constant,
// written once here) and several times closer. The two differ because the two stages fit
// different forms: attack fits phi^p to sin(pi*phi/2), decay fits 1 - t^q to cos(pi*t/2).
// The measured bounds are asserted in tests/test_component_state_io.cpp. Every OTHER curve on
// a migrated blob still lifts to the neutral — only the fades had a prior shape to reproduce.
inline constexpr double kTriggerFadeLiftAttackCurve = 0.6133;
inline constexpr double kTriggerFadeLiftDecayCurve  = 1.7437;

inline constexpr std::uint32_t kPerformanceStateVersion = 2;

// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 16;  // v15 + Rate and the pitch offset
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;

// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
// reads through the legacy walk; everything at or above it shares the v8 record shape and
// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a
// future bump does not silently push the previous format back into the zone reader.
inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;

// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is
// self-describing, mirroring the envelope's version constants.
inline constexpr std::uint32_t kParamsFilterVersion = 9;

// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
inline constexpr std::uint32_t kParamsCurveVersion = 10;

// v10 + the loop-crossfade frame count.
inline constexpr std::uint32_t kParamsLoopVersion = 11;

// v11 + the velocity->pitch curve; the appended tail branches on THIS, never on
// kParamsPayloadVersion. The filter curve's v12 domain re-tag needs no branch of its own — a
// pre-v12 curve's y values are already valid bipolar ones.
inline constexpr std::uint32_t kParamsVelocityVersion = 12;

// v12 + the dual Staged/Spline state; the appended tail branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsSplineVersion = 13;

// v13 + the bake Hold division; the appended tail branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;

// v14 + the master-bus limiter enable; the appended byte branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsLimiterVersion = 15;

// v15 + the playback rate and the baseline pitch offset; the appended pair branches on THIS,
// never on kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsRateVersion = 16;

// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
// seconds domain is consistent across both paths. No constant is baked in.

// --- Combined component state (VST3 setState/getState) -----------------------
//
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
// velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
// mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
// LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
// path + intrinsics + display name per referenced sample; wire shape at
// kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the
// minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
// CURRENT params payload (its own self-describing version). The envelope grows fields on an
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
//
// An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
// blob) falls back to the field's default rather than silencing the instance.
//
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
// loaded capture — and an EMPTY sample-refs table, which the shell lifts once via the
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
// publish):
//   * v11 blob       -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
//   * v10 blob       -> the v11 fields minus instanceGuid (empty — minted on first publish).
//   * v9 blob        -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
//   * v8 blob        -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
//   * v7 blob        -> unity master gain.
//   * v6 blob        -> voice defaults.
//   * v5 blob        -> no velocity byte.
//   * v4 blob        -> no marker.
//   * v3 blob        -> no channel mode.
//   * v2 blob        -> zones-only, no separate selection: the adopted first zone supplies BOTH.
//   * v1 blob        -> {mono, 0, mid, id, default params}: single-selection lift.
//   * empty/unknown  -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
//
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
//
// WHY THE ASSIGNMENT MARKER PERSISTS. The last-consumed assignment generation stops a
// re-opened instance re-applying a stale assign_request the user already got and then
// manually changed away from: on re-open the instance re-reads the pending request, and only
// a generation STRICTLY GREATER than this stored marker re-applies (see
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
// assign (generation >= 1) still applies. It is the instrument's own state, never written to
// the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
// no velocity byte lifts to this, audible-but-not-hot.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;

struct ComponentState {
    std::string selectionId;                     // the loaded capture; "" = no pick
    InstrumentParams params;                     // the ONE parameter set governing it
    ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
    // Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
    // false (implicit), the shell auto-defaults the mode from the loaded capture's channel
    // count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's
    // choice is never fought. Pre-v9 blobs lift to false (implicit).
    bool channelModeExplicit = false;
    std::int64_t lastConsumedAssignGeneration = 0;  // last assign_request generation consumed
    // Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
    // the Sample-view preview button retains the user's chosen strike velocity across saves.
    std::uint8_t previewVelocity = kPreviewVelocityDefault;
    // Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
    // reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
    // byte-identically.
    int voiceCount = kDefaultVoiceCount;         // polyphony bound, kMinVoiceCount..kMaxVoiceCount
    VoiceMode voiceMode = VoiceMode::Poly;       // Poly | Mono (last-note-priority held stack)
    MonoTrigger monoTrigger = MonoTrigger::Retrigger;  // mono takeover: Retrigger | Legato
    // Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
    // ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
    // voice sum — never per voice. Default unity reproduces pre-master-gain output
    // byte-identically.
    double masterGainLinear = 1.0;
    // Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
    // bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
    // decodes straight from these; NO bridge/extension read is required for playback. A
    // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
    // path once (then re-saves self-contained).
    SampleRefs sampleRefs;
    // The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
    // ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
    // the key is stable across sessions. Empty = never published (a fresh or pre-v11
    // instance); the processor mints one on first publish, and RE-mints when the publish
    // plan detects this state was cloned onto another track (FX copy / track duplication).
    std::string instanceGuid;
};

inline constexpr std::uint32_t kComponentStateVersion = 11;

// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;

// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
// 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
// length + bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;

// Everything through the master gain, no channel-mode explicit flag. Retained so
// deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;

// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
// in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;

// Selection + params + channel mode + consumed marker + preview velocity + voice system, no
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;

// Selection + params + channel mode + consumed marker + preview velocity, no voice-system
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
// {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;

// Selection + params + channel mode + consumed marker, no preview velocity. Retained so
// deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;

// Selection + params + channel mode, no consumed marker. Retained so
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
inline constexpr std::uint32_t kSelectionModeV4Version = 4;

// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
// blob to {mono, selection, params}.
inline constexpr std::uint32_t kSelectionV3Version = 3;

// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);

// The full instance state parsed back from IBStream bytes (setState). Tolerant of
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
// above so already-saved instances restore cleanly.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
                                         double projectRate);

// --- Instance state (VST3 setState/getState) --------------------------------
//
// The original v1 instance state was which bank sample it plays — a single string id.
//
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
// state, not the bank's first sample), never throwing across the host boundary. Retained
// for the v1 back-compat lift in deserializeComponentState.

inline constexpr std::uint32_t kSelectionStateVersion = 1;

// The selected-sample id serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);

// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);

}  // namespace reasampler::instrument::map
