// See bake_plan.h.

#include "core/instrument/bake/bake_plan.h"

#include <algorithm>
#include <cmath>

#include "core/instrument/engine/loop/loop_span.h"  // resolveLoop (the one sustain-loop fold)
#include "core/instrument/engine/time_stretch.h"    // clampStretchRate (THE rate bound)
#include "core/instrument/engine/voice.h"           // kDeclickFrames (the terminal ramp length)
#include "core/instrument/map/trigger_seam.h"       // triggerPlayLength (the one span formula)

namespace reasampler::instrument::bake {

using note::NoteProgram;
using note::ResolvedNote;

namespace {

// Seconds -> frames by round-half-away-from-zero, the one conversion every field here uses,
// so the window and its event frames cannot round against each other. Reports failure
// rather than clamping: the double->int64 narrowing below is undefined once the product
// leaves int64's range, which a legal offset magnitude reaches long before that.
bool toFrames(double seconds, int rate, std::int64_t& out) {
    const double frames = seconds * static_cast<double>(rate);
    const auto ceiling = static_cast<double>(kMaxBakeFrames);
    if (!(frames >= -ceiling && frames <= ceiling)) return false;  // also catches NaN
    out = static_cast<std::int64_t>(std::llround(frames));
    return true;
}

// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
//
//   Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
//   feeds the stretcher at it, so either way the source is consumed at that many frames per
//   output frame. Taken through the engine's clamp, because that is the value Voice::start
//   actually plays.
//
//   The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
//   advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
//   rate alone, which is the only sense in which the two are decoupled there.
//
// A Gate release is ticked per output frame, so neither term touches it.
double playbackStretch(const PlayParams& play, int velocity) {
    double down = 0.0;
    if (play.pitchEngine == PitchEngine::Varispeed) {
        down = (std::min)(0.0, kVelocityPitchRangeSemitones *
                                   play.pitchVelocityCurve.eval(velocity));
        // Taken as a bound rather than exactly, like the velocity term beside it: an upward
        // offset only makes the read faster, and every term in this sum is a floor.
        down += (std::min)(0.0, play.pitchOffsetSemitones);
        if (play.pitchEnv.enabled) {
            // A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
            // the staged AHD only ever travels between 0 and the peak.
            down += play.pitchSpline.mode == EnvMode::Spline
                        ? -std::fabs(play.pitchEnv.peakSemitones)
                        : (std::min)(0.0, play.pitchEnv.peakSemitones);
        }
    }
    return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
}

// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
// rather than starting a voice already off the end.
std::int64_t effectiveStart(const SampleData& dialed) {
    const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
    const std::int64_t start = dialed.startFrame;
    return (start < 0 || start >= frameCount) ? 0 : start;
}

}  // namespace

bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
                         std::int64_t frameCount) {
    // resolveLoop already refuses a non-Gate voice, so this is exactly "Gate over a loop the
    // read path will honour" — the engine's decision, not a second reading of the fields.
    return engine::loop::resolveLoop(loop, crossfadeFrames, frameCount,
                                     mode == PlayMode::Gate)
        .active;
}

bool bakeWindowNeedsHold(const SampleData& dialed) {
    return bakeWindowNeedsHold(dialed.play.playMode, dialed.loop, dialed.loopCrossfadeFrames,
                               static_cast<std::int64_t>(dialed.frames.size()));
}

NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
                               note::Division hold, note::Velocity velocity) {
    NoteProgram p;  // a quarter note, capture opening at note-on
    p.velocity = velocity;
    if (renderSampleRate <= 0) return p;
    const double rate = static_cast<double>(renderSampleRate);
    const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
    const std::int64_t start = effectiveStart(dialed);
    const double stretch = playbackStretch(dialed.play, p.velocity.value());
    const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;

    double endOffsetSeconds = 0.0;
    if (dialed.play.playMode == PlayMode::Trigger) {
        // Trigger ignores note-off entirely: the sound ends when the read head reaches the
        // play span's end. The note is that span, so the window closes on the sound rather
        // than on a length the voice never consulted.
        const std::int64_t span =
            map::triggerPlayLength(effectiveLengthFraction(dialed.play), frameCount, start);
        p.length = note::lengthOfSeconds(static_cast<double>(span) / rate * stretch);
    } else if (bakeWindowNeedsHold(dialed)) {
        // The loop cycles for as long as the note is held, so the hold IS the length, and the
        // release is the one stage that runs after note-off.
        p.length = note::lengthOfDivision(hold);
        endOffsetSeconds = releaseSeconds;
    } else {
        // Gate with no loop: the read head runs off the source and frees the voice whether or
        // not the gate is still down, so the maximal sound is the whole post-start span held.
        // Exact, not a ladder rung: a source longer than the ladder's top rung would otherwise
        // take that rung and release mid-sound, and rounding up to one costs trailing silence
        // on every other source.
        const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - start);
        p.length = note::lengthOfSeconds(static_cast<double>(postStart) / rate * stretch);
        endOffsetSeconds = releaseSeconds;
    }
    // The voice rings its last output out over kDeclickFrames instead of hard-cutting it, and
    // that ramp starts where the derivations above end. Added on every path, not just the
    // Preserve one that has the ramp today: trailing silence is free, a hard cut is not.
    endOffsetSeconds += static_cast<double>(kDeclickFrames) / rate;
    p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
    return p;
}

PlannedBake planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) {
    const PlannedBake empty{std::nullopt, BakeRefusal::EmptyWindow};
    const PlannedBake tooLong{std::nullopt, BakeRefusal::PastFrameCeiling};
    if (resolved.windowCollapsed) return empty;
    if (sampleRate <= 0) return empty;

    // The render starts at whichever comes first, note-on or the capture opening. A POSITIVE
    // start offset is legal and means the capture opens after the note — so the head is
    // rendered and discarded, never folded away by sliding note-on later inside the window.
    const double renderStartSeconds = (std::min)(resolved.captureStartSeconds, 0.0);

    BakePlan plan;
    plan.sampleRate = sampleRate;
    if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
        return tooLong;
    if (plan.totalFrames <= 0) return empty;
    if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
                  plan.leadInFrames))
        return tooLong;
    if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return tooLong;
    if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
                  plan.noteOffFrame))
        return tooLong;
    // Each field cleared the ceiling alone; the render holds their sum.
    if (plan.renderFrames() > kMaxBakeFrames) return tooLong;

    plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
    plan.note = std::clamp(rootNote, 0, 127);
    plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
    return PlannedBake{plan, BakeRefusal::None};
}

}  // namespace reasampler::instrument::bake
