#pragma once
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
//
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
// configured an out-of-line render would put a call — and the envelope ticks behind it —
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.

#include <cmath>
#include <cstdint>
#include <vector>

#include "core/audio/peaks.h"
#include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/velocity_curve.h"

namespace reasampler {

using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;

// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
// reference-frequency needed.
inline double pitchRatio(int note, int rootNote) {
    return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
}

// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the
// ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
// At the root note the offset is 0 regardless of keyTrack.
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
    const double semis = static_cast<double>(note - rootNote) * keyTrack;
    return std::pow(2.0, semis / 12.0);
}

// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
// output as a pre-cut reference, and the first frame after the restart seeds a compensation
// equal to (reference - that frame's raw new output), summed in ungated and decaying by
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
// compensation and kept the full click — the difference-seed has no such hole. Off by
// default so the bare core stays byte-identical to the pre-fix engine; the processor
// shell opts in.
inline constexpr double kDeclickDecay = 0.95;  // per-frame decay of the compensation
inline constexpr double kDeclickFloor = 1e-4;  // below this the ramp is done (~ -80 dB)

// A single voice: one active note playing the loaded capture, repitched and enveloped.
// Reads the sample by fractional frame position with linear interpolation, advancing by the
// pitch ratio; loops the sustain region for held notes past the loop end.
class Voice {
public:
    // Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
    // instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
    // AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
    // seconds at load). Preserve shifters must already be pre-sized
    // (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe,
    // no allocation) since it runs on the audio thread inside process(). Byte-identical to
    // the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
    // amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true
    // and this voice is currently active (a takeover/steal restart, not a fresh start), arms
    // the difference-seeded declick compensation on the first frame after the restart (see
    // kDeclickDecay above). A fresh start never declicks.
    void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);

    // Mono legato takeover: re-pitch this active voice to `note` without touching the
    // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
    // engines pick the new baseRatio_ up on the next frame. No-op on an idle voice.
    void retune(int note);

    // Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
    // ignores note-off and plays through to its play length).
    void release();

    // Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
    // no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
    // RT-safe: no allocation, no lock.
    void hardStop() { active_ = false; }

    // True while producing (or about to produce) sound, including any declick ring-out
    // tail past the note's playable span.
    bool active() const { return active_; }
    // True while sounding a playable note — active and the amplitude envelope hasn't
    // finished. A voice ringing out a declick tail past note end is active() but not
    // soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
    // ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
    bool soundingNote() const { return active_ && !amplitudeDone_; }
    int note() const { return note_; }
    // Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
    std::uint64_t startOrder() const { return startOrder_; }
    void setStartOrder(std::uint64_t order) { startOrder_ = order; }
    bool releasing() const { return releasing_; }
    // The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
    // meaningful while active().
    PitchEngine pitchEngine() const { return pitchEngine_; }

    // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
    // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
    // runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
    // Idempotent: a re-presize to the same window is a cheap no-op.
    void presizePreserveShifters(std::int64_t windowFrames);

    // Renders one frame's contribution, advancing the read head and envelope by one output
    // frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
    // with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
    // Mono path (channel 0 only).
    AudioSample renderFrame() {
        AudioSample discard = 0.0f;
        return advanceFrame(/*stereo=*/false, discard);
    }

    // Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
    // envelope by exactly one frame (the envelope ticks once per frame, shared across both
    // channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
    // on the same conditions as the mono path, writing 0 to both.
    void renderFrameStereo(AudioSample& l, AudioSample& r) {
        r = 0.0f;
        l = advanceFrame(/*stereo=*/true, r);
    }

private:
    // True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
    // sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
    // by the output anchor, the Preserve feed, and the start()-time ring prime.
    bool sustainLoopUsable() const {
        if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
        const SampleLoop& loop = sample_->loop;
        return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
               loop.end <= static_cast<std::int64_t>(sample_->frames.size());
    }

    // This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
    // output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
    // shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
    // source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
    // advanceFrame frees the voice.
    double tickAmplitude() {
        double amp;
        if (playMode_ == PlayMode::Gate) {
            amp = env_.tick();
            if (env_.finished()) amplitudeDone_ = true;
        } else {
            // Anchored to the source offset so fades land on the same source frames under
            // either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
            // advanceFrame; finished() here is the belt to that suspenders.
            amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
            if (trigEnv_.finished()) amplitudeDone_ = true;
        }
        return amp;
    }

    // Seeds the takeover compensation on the first frame after a restart: the ramp is the
    // actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
    // applied ungated so the boundary frame reproduces the old level exactly.
    void seedDeclick() {
        // The weight starts at 1.0 so this frame's output is `out*(1-1) + ref*1 == ref` —
        // exact boundary identity whatever the new envelope's first value. Each subsequent
        // frame adds `w*(ref − outCurrent)` then decays w, so output is provably bounded by
        // max(|ref|, |outCurrent|) — mid-ramp overshoot is impossible even if outCurrent
        // rises while the weight is still significant. (An earlier revision stored the frozen
        // difference (ref − x₀), which could exceed full scale if outₙ rose while that
        // residue was still large.)
        declickPending_ = false;
        declickWeight_ = 1.0;  // one weight for both channels
        // ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
        // if ref ≈ 0 there is nothing to blend.
        declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
                          declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
    }

    // Shared read/advance for both render paths: computes the interpolated per-channel
    // value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
    // the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
    // whether the second channel is read (into `outR`). Returns the channel-0 value.
    //
    // INLINE BY CONSTRAINT — see the file header.
    AudioSample advanceFrame(bool stereo, AudioSample& outR) {
        if (!active_ || sample_ == nullptr) {
            if (stereo) outR = 0.0f;
            return 0.0f;
        }

        const std::vector<AudioSample>& pcm = sample_->frames;
        const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
        // Read the second channel only for a genuinely stereo sample; a mono sample plays
        // dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
        const bool haveR = stereo && sample_->channelCount() == 2;
        const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;

        // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A
        // valid, non-zero-length loop wraps the read head back into [start, end); a
        // zero-length loop is "no loop". Under Preserve the loop is over the source read
        // (loop the source, shift the output).
        const SampleLoop& loop = sample_->loop;
        const bool loopUsable = sustainLoopUsable();
        if (loopUsable) {
            const double loopLen = static_cast<double>(loop.end - loop.start);
            while (readPos_ >= static_cast<double>(loop.end)) {
                readPos_ -= loopLen;  // wrap by exactly one loop length, preserving phase.
            }
        }

        // Trigger frees once the read head reaches playEnd; the envelope also finishes at the
        // same count, either latches idle.
        const bool triggerRanOff =
            playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
        // Ran off the sample end with no usable loop -> voice is done, except an in-flight
        // takeover declick rings out here instead of hard-cutting — dropping it would
        // re-introduce a step on exactly the path the ramp exists for (a restart whose new
        // play span ends within the ramp). With no declick (the common case) this is
        // byte-identical to the plain idle-out.
        if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
            if (declickPending_) seedDeclick();
            if (declickActive_) {
                // Bounded blend at silence: outCurrent == 0, so the blend is
                // w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
                // floor-checked on the weight itself.
                const double l = declickWeight_ * declickRefL_;
                const double r = declickWeight_ * declickRefR_;  // same weight both channels
                declickWeight_ *= kDeclickDecay;
                if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
                    declickActive_ = false;
                    active_ = false;
                }
                lastOutL_ = l;
                lastOutR_ = stereo ? r : l;
                if (stereo) outR = static_cast<AudioSample>(r);
                return static_cast<AudioSample>(l);
            }
            active_ = false;
            if (stereo) outR = 0.0f;
            return 0.0f;
        }

        // Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
        const double amp = tickAmplitude();
        const double gain = amp * velocityGain_;
        const double pitchEnvSemis = pitchEnv_.tick();

        // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
        // pow entirely — no per-frame transcendental on the common path.
        const double envFactor =
            (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);

        double outL, outRlocal = 0.0;
        if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
            // Feed the shifters the source stream at unity rate (duration held) and transpose
            // the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
            // shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
            // rings were primed with that window at start()), under the same sustain-loop wrap
            // rule, reading integer source frames (nothing to interpolate). Past the last real
            // frame the shifter's writer is frozen — it recycles the real tail it already holds.
            if (loopUsable) {
                const std::int64_t loopLen = loop.end - loop.start;
                while (feedPos_ >= loop.end) feedPos_ -= loopLen;
            }
            // feedPos_ runs one window ahead of readPos_; the last real source frame is
            // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
            // the source is exhausted — feeding the held last sample instead would give the
            // splice correlation a DC plateau it can't align on (periodic troughs at the splice
            // cadence, growing toward the note end). Freezing the shifter's writer means no
            // padding ever enters the ring, so the splice machinery keeps recycling the frozen
            // all-real tail — a continuous tone through the voice's own end. The sustain-loop
            // path never gets here: the wrap above keeps feedPos_ < loop.end forever.
            const std::int64_t feedBound =
                (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
                    ? playEnd_ : frameCount;
            const bool exhausted = feedPos_ >= feedBound;
            if (exhausted) shiftL_.freezeTail();  // idempotent; input ignored while frozen
            const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
            const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
            const double shift = baseRatio_ * envFactor;
            shiftL_.setShiftRatio(shift);
            const double shiftedL = static_cast<double>(shiftL_.process(feedL));
            outL = shiftedL * gain;
            if (stereo) {
                if (haveR && shiftR_.configured()) {
                    // Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
                    // splice decisions via processLinked — one correlation search, one lag, one
                    // splice schedule for both channels (standard stereo SOLA). An independent
                    // per-channel search re-drew an inter-channel offset of up to +/-maxLag at
                    // every splice: stereo image wander at the splice cadence + mono-sum
                    // combing. Each shifter is still processed EXACTLY ONCE per output frame
                    // (never twice — that would advance its heads twice and corrupt the state).
                    // Gated on haveR so a MONO sample never touches shiftR_ — start() only
                    // primes it for genuinely stereo samples, and a stale un-primed ring must
                    // not leak a previous note.
                    if (exhausted) shiftR_.freezeTail();
                    const AudioSample feedR =
                        feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
                    shiftR_.setShiftRatio(shift);
                    outRlocal =
                        static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
                        gain;
                } else {
                    // Mono sample in stereo mode (dual-mono): shiftL_ already produced the
                    // shifted value from the mono feed; mirror it to R. Do NOT call
                    // shiftL_.process again this frame.
                    outRlocal = shiftedL * gain;
                }
            }
            ++feedPos_;
            // Preserve advances the read head at the SOURCE rate (duration preserved).
            ratio_ = 1.0;
        } else {
            // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
            // pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
            // the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
            //
            // Linear interpolation between the two bracketing SOURCE frames at the read head.
            // For the loop case, the second point wraps to loopStart so the seam is continuous.
            const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
            const double frac = readPos_ - static_cast<double>(i0);
            std::int64_t i1 = i0 + 1;
            if (loopUsable && i1 >= loop.end) {
                i1 = loop.start;  // seamless wrap for the interpolation partner.
            }
            const bool i0ok = (i0 >= 0 && i0 < frameCount);
            const bool i1ok = (i1 >= 0 && i1 < frameCount);
            const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
                                ((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
                                 (i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
            outL = srcL * gain;
            if (stereo) {
                const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
                                    ((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
                                     (i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
                outRlocal = srcR * gain;
            }
            ratio_ = baseRatio_ * envFactor;
        }

        // Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
        // restart, seed the blend weight at 1.0 so this frame's output is
        //   outₙ*(1−w) + ref*w  =  out*(1−1) + ref*1  =  ref  (exact boundary identity).
        // Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
        // kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
        // frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
        // [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
        // while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
        // extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
        // one extra subtract.
        if (declickPending_) seedDeclick();
        if (declickActive_) {
            const double addL = declickWeight_ * (declickRefL_ - outL);
            const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
            outL += addL;
            if (stereo) outRlocal += addR;
            declickWeight_ *= kDeclickDecay;  // one shared weight — both channels decay together
            if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
                declickActive_ = false;
            }
        }

        if (stereo) outR = static_cast<AudioSample>(outRlocal);

        // Track the value this voice actually contributed THIS frame (post-gain, incl. any
        // running declick) — a future takeover restart seeds its declick from exactly this. In
        // a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
        // of a mono sample), so a later stereo takeover still has a sane R seed.
        lastOutL_ = outL;
        lastOutR_ = stereo ? outRlocal : outL;

        readPos_ += ratio_;

        // A finished amplitude envelope frees the voice — unless a takeover declick still
        // rings: the envelope contributes 0 from here on, so the remaining frames are the bare
        // ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
        if (amplitudeDone_ && !declickActive_) {
            active_ = false;
        }
        return static_cast<AudioSample>(outL);
    }

    bool active_ = false;
    bool releasing_ = false;
    int note_ = 0;
    double velocityGain_ = 1.0;
    double baseRatio_ = 1.0;     // 2^((note-root)/12): the un-modulated repitch ratio
    double ratio_ = 1.0;         // fractional source frames advanced per output frame (this frame)
    double readPos_ = 0.0;       // fractional frame index into the sample
    const SampleData* sample_ = nullptr;

    // Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
    // playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
    // readPos_ >= playEnd_).
    PlayMode playMode_ = PlayMode::Gate;
    AdsrEnvelope env_;
    TriggerEnvelope trigEnv_;
    std::int64_t startFrame_ = 0;  // clamped initial read frame; Trigger fade offset origin
    std::int64_t playEnd_ = 0;   // Trigger: source-frame end; Gate: unused
    bool amplitudeDone_ = false; // set when the active amplitude envelope finished

    // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
    // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
    //
    // The shifter rings are primed at start() with the first window of the actual upcoming
    // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
    // silence, and splices always land in real history. feedPos_ is the integer source frame
    // fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
    // sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
    // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
    // splice machinery recycles the frozen real tail through the note end (see advanceFrame).
    // primeBuf_ is the presized scratch the prime stream is assembled into.
    PitchEngine pitchEngine_ = PitchEngine::Varispeed;
    PitchEnvelope pitchEnv_;
    PitchShifter shiftL_;
    PitchShifter shiftR_;
    std::int64_t feedPos_ = 0;
    std::vector<AudioSample> primeBuf_;

    // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
    // records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
    // restart calls seedDeclick to arm the bounded blend:
    //   outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
    //   L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
    // lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
    // between) must record the same pre-cut reference, not a phantom 0. The whole declick
    // state is cleared on a fresh (non-takeover) start.
    bool declickPending_ = false;
    bool declickActive_ = false;
    double declickRefL_ = 0.0;    // clamped pre-cut reference (bounded blend target)
    double declickRefR_ = 0.0;
    double declickWeight_ = 0.0;  // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
    double lastOutL_ = 0.0;
    double lastOutR_ = 0.0;

    std::uint64_t startOrder_ = 0;
};

}  // namespace reasampler
