// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Andrea De Murtas
//
// Headless DSP tests: numerical safety across rates, block sizes and parameter
// extremes, gate behaviour, state round-trips, the latency contract and a
// warn-only aliasing measurement. Exits non-zero on failure; run by CI on
// every platform, and registered with CTest.

#include <juce_audio_utils/juce_audio_utils.h>
#include <cmath>
#include <functional>
#include <iostream>
#include <limits>
#include <vector>
#include "../src/PluginProcessor.h"
#include "../src/NoiseGate.h"
#include "../src/Presets.h"

namespace
{

int failures = 0;

void check(bool condition, const juce::String& what)
{
    std::cout << (condition ? "  ok: " : "FAIL: ") << what << std::endl;
    if (! condition)
        ++failures;
}

void note(const juce::String& what)
{
    std::cout << "  ..  " << what << std::endl;
}

void setParam(GjallaAudioProcessor& p, const char* id, float value)
{
    auto* rp = p.apvts.getParameter(id);
    jassert(rp != nullptr);
    rp->setValueNotifyingHost(rp->convertTo0to1(value));
}

// Everything that can colour or delay the signal turned off, so that what is
// left is the oversampling round trip and the preamp running at unity.
void neutralise(GjallaAudioProcessor& p)
{
    setParam(p, pid::inTrim, 0.0f);
    setParam(p, pid::gateOn, 0.0f);
    setParam(p, pid::pedalOn, 0.0f);
    setParam(p, pid::ampGain, 0.0f);
    setParam(p, pid::tight, 20.0f);
    setParam(p, pid::bass, 0.0f);
    setParam(p, pid::mid, 0.0f);
    setParam(p, pid::treble, 0.0f);
    setParam(p, pid::presence, 0.0f);
    setParam(p, pid::master, 0.0f);
    setParam(p, pid::cabMode, (float) (int) choices::CabMode::off);
    setParam(p, pid::frost, 0.0f);
    setParam(p, pid::outTrim, 0.0f);
}

// Renders a generated mono signal through the processor (duplicated onto both
// channels) and returns the first output channel.
std::vector<float> render(GjallaAudioProcessor& p, double sr, int blockSize, double seconds,
                          const std::function<float(int)>& gen)
{
    const int total = ((int) std::ceil(seconds * sr / blockSize)) * blockSize;
    std::vector<float> out;
    out.reserve((size_t) total);

    juce::AudioBuffer<float> buffer(2, blockSize);
    juce::MidiBuffer midi;

    for (int pos = 0; pos < total; pos += blockSize)
    {
        for (int i = 0; i < blockSize; ++i)
        {
            const float v = gen(pos + i);
            buffer.setSample(0, i, v);
            buffer.setSample(1, i, v);
        }
        p.processBlock(buffer, midi);
        for (int i = 0; i < blockSize; ++i)
            out.push_back(buffer.getSample(0, i));
    }
    return out;
}

// Every parameter at its maximum stacks +9 dB of presence, +3 dB of frost and
// +24 dB of output trim on top of a power section that already saturates at
// unity, so a legitimate worst case is around x64. The limit is set well above
// that: what this is looking for is a runaway, not a loud signal.
constexpr float sanityLimit = 256.0f;

bool allFinite(const std::vector<float>& v, float& worst)
{
    worst = 0.0f;
    bool ok = true;
    for (auto x : v)
    {
        if (! std::isfinite(x))
            return (worst = std::numeric_limits<float>::infinity()), false;
        worst = juce::jmax(worst, std::abs(x));
        ok = ok && worst <= sanityLimit;
    }
    return ok;
}

bool allFinite(const std::vector<float>& v)
{
    float worst = 0.0f;
    return allFinite(v, worst);
}

float peakOf(const std::vector<float>& v, int from, int to)
{
    float m = 0.0f;
    for (int i = juce::jmax(0, from); i < juce::jmin((int) v.size(), to); ++i)
        m = juce::jmax(m, std::abs(v[(size_t) i]));
    return m;
}

// ---------------------------------------------------------------------------

void testNumericalSafety()
{
    std::cout << "numerical safety across rates, block sizes and extremes" << std::endl;

    const double rates[] { 44100.0, 48000.0, 96000.0 };
    const int blockSizes[] { 1, 3, 16, 64, 512, 1024, 4096 };

    // -1 = every parameter at its minimum, +1 = at its maximum, 0 = defaults.
    for (int extreme = -1; extreme <= 1; ++extreme)
    {
        for (auto sr : rates)
        {
            for (auto blockSize : blockSizes)
            {
                GjallaAudioProcessor p;
                for (auto* param : p.getParameters())
                    if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(param))
                        rp->setValueNotifyingHost(extreme < 0 ? 0.0f
                                                 : extreme > 0 ? 1.0f
                                                 : rp->getDefaultValue());

                p.prepareToPlay(sr, blockSize);

                juce::Random rng(0xC0FFEE + blockSize);
                auto noise = [&rng](int) { return rng.nextFloat() * 0.6f - 0.3f; };
                const auto out = render(p, sr, blockSize, 0.15, noise);

                float worst = 0.0f;
                if (! allFinite(out, worst))
                {
                    check(false, "finite output at " + juce::String(sr, 0) + " Hz, block "
                                 + juce::String(blockSize) + ", extreme "
                                 + juce::String(extreme) + " (peak " + juce::String(worst, 3) + ")");
                    return;
                }
            }
        }
    }
    check(true, "all rates x block sizes x parameter extremes produce finite, bounded output");

    // Hosts are allowed to send fewer samples than prepareToPlay promised.
    {
        GjallaAudioProcessor p;
        p.prepareToPlay(48000.0, 4096);
        juce::Random rng(7);
        const auto out = render(p, 48000.0, 64, 0.1, [&rng](int) { return rng.nextFloat() - 0.5f; });
        check(allFinite(out), "short blocks into a processor prepared for 4096");
    }

    // The chain must not keep producing anything once the input has gone quiet.
    // This is the regression test for the preamp's DC offset: while the offset
    // was left in the signal and taken out by a downstream blocker, it parked
    // every filter state after it at a non-zero operating point, and at 4x the
    // chain settled into a permanent 2 kHz limit cycle at -121 dBFS instead of
    // decaying. biasedShape() removes the offset at source; the tail now runs
    // all the way down into the denormal range, where ScopedNoDenormals flushes
    // it.
    for (int quality = 0; quality <= 1; ++quality)
    {
        GjallaAudioProcessor p;
        setParam(p, pid::gateOn, 0.0f);
        setParam(p, pid::quality, (float) quality);
        p.prepareToPlay(48000.0, 512);

        const auto out = render(p, 48000.0, 512, 2.0, [](int i)
        {
            return i < 4800 ? 0.5f * std::sin(juce::MathConstants<float>::twoPi * 220.0f
                                              * (float) i / 48000.0f)
                            : 0.0f;
        });

        const auto label = juce::String(quality == 0 ? "Eco" : "Full");
        const auto tail = peakOf(out, (int) (1.5 * 48000), (int) out.size());

        // -400 dBFS is past the bottom of single precision's normal range: a
        // tail this small is either a true zero or a denormal already flushed
        // by ScopedNoDenormals, and either way nothing is still ringing.
        check(tail < 1.0e-20f, label + ": the tail after a burst decays away, peak "
                               + juce::String(juce::Decibels::gainToDecibels(tail, -400.0f), 1)
                               + " dB");

        // ... and the flush must not fire while the note is still ringing.
        const auto duringDecay = peakOf(out, 4800, (int) (0.3 * 48000));
        check(duringDecay > 0.0f, label + ": the amp still rings after the input stops");
    }
}

void testSilenceInSilenceOut()
{
    std::cout << "silence in, gate on, silence out" << std::endl;

    // The gate stage on its own has to be bit exact: it is the only thing in
    // the chain that can produce a true zero.
    {
        gjalla::NoiseGate gate;
        gate.prepare(48000.0, 2);
        juce::AudioBuffer<float> buffer(2, 512);
        buffer.clear();
        bool exact = true;
        for (int block = 0; block < 100; ++block)
        {
            buffer.clear();
            gate.process(buffer, -50.0f, true);
            for (int ch = 0; ch < 2; ++ch)
                for (int i = 0; i < 512; ++i)
                    exact = exact && buffer.getSample(ch, i) == 0.0f;
        }
        check(exact, "the gate stage itself outputs bit-exact zeros");
    }

    // End to end the DC blockers and the cab filters have to settle too.
    GjallaAudioProcessor p;
    p.prepareToPlay(48000.0, 256);
    setParam(p, pid::gateOn, 1.0f);
    setParam(p, pid::gateThresh, -50.0f);

    const auto out = render(p, 48000.0, 256, 1.5, [](int) { return 0.0f; });
    const auto tail = peakOf(out, (int) (1.0 * 48000), (int) out.size());
    check(tail < 1.0e-7f, "full chain output after 1 s of silence is below -140 dBFS, peak "
                          + juce::String(juce::Decibels::gainToDecibels(tail, -200.0f), 1) + " dB");
}

void testGateIsClickFree()
{
    std::cout << "gate applies its gain without clicking" << std::endl;

    constexpr double sr = 48000.0;
    constexpr int n = (int) (sr * 0.6);
    constexpr float freq = 200.0f;
    constexpr float amp = 0.5f;

    gjalla::NoiseGate gate;
    gate.prepare(sr, 1);

    juce::AudioBuffer<float> buffer(1, n);
    for (int i = 0; i < n; ++i)
    {
        // loud for 0.3 s, then silence: the gate has to open and close
        const auto v = i < n / 2
            ? amp * std::sin(juce::MathConstants<float>::twoPi * freq * (float) i / (float) sr)
            : 0.0f;
        buffer.setSample(0, i, v);
    }

    gate.process(buffer, -40.0f, true);

    // The sine's own slew is 2*pi*f/sr*amp; the gate ramp can add at most the
    // attack coefficient times the sample value on top. Anything much beyond
    // that is a step discontinuity, i.e. a click.
    const auto signalSlew = juce::MathConstants<float>::twoPi * freq / (float) sr * amp;
    const auto rampSlew = (1.0f - std::exp(-1.0f / (0.0005f * (float) sr))) * amp;
    const auto budget = 1.5f * (signalSlew + rampSlew);

    float worst = 0.0f;
    for (int i = 1; i < n; ++i)
        worst = juce::jmax(worst, std::abs(buffer.getSample(0, i) - buffer.getSample(0, i - 1)));

    check(worst <= budget, "largest sample-to-sample step " + juce::String(worst, 5)
                           + " within the budget " + juce::String(budget, 5));

    // and it really did close
    float tail = 0.0f;
    for (int i = n - 100; i < n; ++i)
        tail = juce::jmax(tail, std::abs(buffer.getSample(0, i)));
    check(tail == 0.0f, "the gate reaches a true zero after the signal stops");
}

void testStateRoundTrip()
{
    std::cout << "state round-trip" << std::endl;

    GjallaAudioProcessor a;
    juce::Random rng(0x5EED);
    for (auto* param : a.getParameters())
        if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(param))
        {
            // snap through the parameter's own range so stepped parameters
            // (bools, choices) hold a legal value, as any host set would
            const float denorm = rp->convertFrom0to1(rng.nextFloat());
            rp->setValueNotifyingHost(rp->convertTo0to1(denorm));
        }

    // The IR path is state too, and it is the one piece that is not a parameter.
    const juce::String irPath = "/some/where/frozen.wav";
    a.apvts.state.setProperty(prop::irPath, irPath, nullptr);

    juce::MemoryBlock state;
    a.getStateInformation(state);

    GjallaAudioProcessor b;
    b.setStateInformation(state.getData(), (int) state.getSize());

    bool identical = true;
    for (auto* param : a.getParameters())
        if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(param))
        {
            auto* other = b.apvts.getParameter(rp->paramID);
            identical = identical && other != nullptr && other->getValue() == rp->getValue();
        }

    check(identical, "every parameter reloads to exactly the value it was saved with");
    check(b.apvts.state.getProperty(prop::irPath).toString() == irPath,
          "the user IR path survives the round trip");
    check((int) b.apvts.state.getProperty("stateVersion", 0) == GjallaAudioProcessor::stateVersion,
          "the state carries its version");

    // A second save of the reloaded state must be byte-identical to the first.
    juce::MemoryBlock again;
    b.getStateInformation(again);
    check(again == state, "re-saving the reloaded state reproduces the same bytes");
}

void testLatency()
{
    std::cout << "reported latency matches measured latency" << std::endl;

    // The convolution engine has to be the zero-latency one, or switching the
    // cabinet to User IR would silently change the plugin's latency.
    {
        juce::dsp::Convolution convolution;
        convolution.prepare({ 48000.0, 512, 2 });
        check(convolution.getLatency() == 0,
              "juce::dsp::Convolution is configured with zero latency");
    }

    constexpr double sr = 48000.0;
    constexpr int blockSize = 512;

    int reported = -1;

    for (int quality = 0; quality <= 1; ++quality)
    {
        GjallaAudioProcessor p;
        neutralise(p);
        setParam(p, pid::quality, (float) quality);
        p.prepareToPlay(sr, blockSize);

        const auto latency = p.getLatencySamples();
        if (reported < 0)
            reported = latency;
        else
            check(latency == reported,
                  "Eco and Full report the same latency (" + juce::String(latency) + ")");

        const auto out = render(p, sr, blockSize, 0.05, [](int i)
        {
            return i == 0 ? 0.05f : 0.0f;   // small, so the shapers stay linear
        });

        int peak = 0;
        float best = 0.0f;
        for (int i = 0; i < (int) out.size(); ++i)
            if (std::abs(out[(size_t) i]) > best)
            {
                best = std::abs(out[(size_t) i]);
                peak = i;
            }

        note(juce::String(quality == 0 ? "Eco" : "Full") + ": reported "
             + juce::String(latency) + ", impulse peak at " + juce::String(peak));

        // A polyphase IIR halfband is minimum phase: its impulse response
        // spreads over a couple of samples rather than arriving as one spike,
        // so the peak can sit one sample either side of the group delay. That
        // is ringing, not latency.
        check(std::abs(peak - latency) <= 1,
              juce::String(quality == 0 ? "Eco" : "Full")
              + ": measured latency is within one sample of the reported figure");
    }

    // The brief expects "zero or near zero" from IIR halfbands. What JUCE's
    // maximum-quality polyphase IIR actually costs is the group delay of the
    // up and down filter pairs: a handful of samples, a fraction of a
    // millisecond. Anything an order of magnitude beyond that would mean the
    // wrong filter type got selected.
    check(reported >= 0 && reported <= 12,
          "reported latency is near zero (" + juce::String(reported) + " samples, "
          + juce::String(1000.0 * reported / sr, 3) + " ms)");
}

void testCabinetModes()
{
    std::cout << "cabinet modes, including a real user IR" << std::endl;

    constexpr double sr = 48000.0;

    for (int mode = 0; mode <= 4; ++mode)
    {
        GjallaAudioProcessor p;
        setParam(p, pid::cabMode, (float) mode);
        p.prepareToPlay(sr, 256);

        juce::Random rng(mode + 1);
        const auto out = render(p, sr, 256, 0.2, [&rng](int) { return rng.nextFloat() - 0.5f; });
        if (! allFinite(out))
        {
            check(false, "cabinet mode " + juce::String(mode) + " produces finite output");
            return;
        }
    }
    check(true, "every cabinet mode produces finite output, including User IR with none loaded");

    // Write a short impulse response to a temp file, load it through the real
    // background loader, and make sure the convolution path runs.
    auto irFile = juce::File::getSpecialLocation(juce::File::tempDirectory)
                      .getChildFile("gjalla-test-ir.wav");
    irFile.deleteFile();

    {
        juce::AudioBuffer<float> ir(1, 1024);
        ir.clear();
        // a decaying pseudo-cab impulse, nothing musical about it
        juce::Random rng(42);
        for (int i = 0; i < 1024; ++i)
            ir.setSample(0, i, (rng.nextFloat() * 2.0f - 1.0f) * std::exp(-(float) i / 200.0f));

        juce::WavAudioFormat wav;
        if (auto stream = irFile.createOutputStream())
        {
            std::unique_ptr<juce::AudioFormatWriter> writer(
                wav.createWriterFor(stream.release(), sr, 1, 24, {}, 0));
            if (writer != nullptr)
                writer->writeFromAudioSampleBuffer(ir, 0, ir.getNumSamples());
        }
    }

    check(irFile.existsAsFile(), "test impulse response written to a temp file");

    {
        GjallaAudioProcessor p;
        neutralise(p);
        setParam(p, pid::cabMode, (float) (int) choices::CabMode::ir);
        p.prepareToPlay(sr, 256);

        // An impulse through the chain, and the energy that follows it. The
        // measurement starts a safe distance after the peak so the
        // oversampling filters' own ringing does not count as IR tail.
        auto tailAfterImpulse = [&]
        {
            const auto out = render(p, sr, 256, 0.1, [](int i)
            {
                return i == 0 ? 0.05f : 0.0f;
            });
            int peak = 0;
            float best = 0.0f;
            for (int i = 0; i < (int) out.size(); ++i)
                if (std::abs(out[(size_t) i]) > best)
                {
                    best = std::abs(out[(size_t) i]);
                    peak = i;
                }
            return peakOf(out, peak + 64, peak + 1024) / juce::jmax(best, 1.0e-9f);
        };

        // Before any IR is loaded, ir mode passes the amp through dry: the
        // engine's default state is a unit impulse. The chain still leaves
        // about -36 dB of tail of its own: the ringing of its low-frequency
        // filters (tight high-pass, DC blockers), whose time constants are
        // longer than the measurement window. So the bound is loose and the
        // real assertions are the relative ones below.
        const auto dryTail = tailAfterImpulse();
        check(dryTail < 0.05f, "ir mode with no IR loaded passes dry (tail "
                               + juce::String(juce::Decibels::gainToDecibels(dryTail), 1) + " dB)");

        p.loadIrFile(irFile);
        check(p.getIrFile() == irFile, "the loaded IR path is remembered in the state");

        // The loader resamples and partitions on its own thread, and the
        // engine only swaps in from inside process(). A fixed sleep here was
        // flaky (a slow macOS CI runner needed more than 400 ms), so poll:
        // run a little silence, re-measure, give up only after a generous
        // ceiling. The test IR is a 1024-tap decaying noise burst, so a
        // convolved impulse must leave substantial energy behind the peak.
        auto wetTail = 0.0f;
        for (int tries = 0; tries < 100; ++tries)
        {
            juce::Thread::sleep(100);
            render(p, sr, 256, 0.05, [](int) { return 0.0f; });
            wetTail = tailAfterImpulse();
            if (wetTail > 10.0f * dryTail && wetTail > 0.05f)
                break;
        }
        check(wetTail > 10.0f * dryTail && wetTail > 0.05f,
              "a loaded user IR audibly convolves (tail "
              + juce::String(juce::Decibels::gainToDecibels(wetTail), 1) + " dB vs dry "
              + juce::String(juce::Decibels::gainToDecibels(dryTail), 1) + " dB)");

        // Clearing must actually unload the engine, not just forget the path.
        // Same background loader, same poll; the pass condition is the dry
        // baseline, not merely quieter than the IR.
        p.clearIr();
        auto clearedTail = wetTail;
        for (int tries = 0; tries < 100; ++tries)
        {
            juce::Thread::sleep(100);
            render(p, sr, 256, 0.05, [](int) { return 0.0f; });
            clearedTail = tailAfterImpulse();
            if (clearedTail < 2.0f * dryTail)
                break;
        }
        check(clearedTail < 2.0f * dryTail,
              "clearing the IR returns ir mode to the dry baseline (tail "
              + juce::String(juce::Decibels::gainToDecibels(clearedTail), 1) + " dB vs dry "
              + juce::String(juce::Decibels::gainToDecibels(dryTail), 1) + " dB)");
        check(p.getIrFile() == juce::File(), "clearing the IR forgets the path");
    }

    irFile.deleteFile();
    check(! irFile.existsAsFile(), "temp impulse response cleaned up");
}

void testPedalModes()
{
    std::cout << "pedal modes" << std::endl;

    constexpr double sr = 48000.0;

    for (int mode = 0; mode <= 2; ++mode)
    {
        GjallaAudioProcessor p;
        setParam(p, pid::pedalOn, 1.0f);
        setParam(p, pid::pedalMode, (float) mode);
        setParam(p, pid::pedalDrive, 10.0f);
        p.prepareToPlay(sr, 256);

        const auto out = render(p, sr, 256, 0.3, [](int i)
        {
            return 0.2f * std::sin(juce::MathConstants<float>::twoPi * 110.0f
                                   * (float) i / 48000.0f);
        });

        if (! allFinite(out) || peakOf(out, (int) (0.15 * sr), (int) out.size()) <= 0.0f)
        {
            check(false, "pedal mode " + juce::String(mode) + " produces sensible output");
            return;
        }
    }
    check(true, "all three pedal modes at full drive produce finite, non-silent output");

    // Switching mode mid-stream must not produce a step: the click guard fades
    // the output out, switches, and fades back in.
    {
        GjallaAudioProcessor p;
        setParam(p, pid::pedalOn, 1.0f);
        p.prepareToPlay(sr, 64);

        juce::AudioBuffer<float> buffer(2, 64);
        juce::MidiBuffer midi;
        std::vector<float> out;
        float previous = 0.0f;
        float worst = 0.0f;

        for (int block = 0; block < 400; ++block)
        {
            for (int i = 0; i < 64; ++i)
            {
                const auto v = 0.2f * std::sin(juce::MathConstants<float>::twoPi * 110.0f
                                               * (float) (block * 64 + i) / (float) sr);
                buffer.setSample(0, i, v);
                buffer.setSample(1, i, v);
            }

            if (block == 100)
                setParam(p, pid::pedalMode, (float) (int) choices::PedalMode::fuzz);
            if (block == 200)
                setParam(p, pid::cabMode, (float) (int) choices::CabMode::boombox);
            if (block == 300)
                setParam(p, pid::quality, (float) (int) choices::Quality::eco);

            p.processBlock(buffer, midi);

            for (int i = 0; i < 64; ++i)
            {
                const auto x = buffer.getSample(0, i);
                if (block > 0)
                    worst = juce::jmax(worst, std::abs(x - previous));
                previous = x;
            }
        }

        // The signal itself is a 110 Hz sine through a distorting chain, so
        // some slew is expected; a real click would be a full-scale jump.
        check(worst < 0.35f, "switching pedal mode, cab mode and quality mid-stream never "
                             "steps by more than " + juce::String(worst, 3));
    }
}

// Warn-only, as the brief asks: measure how much of the output is not a
// harmonic of the input.
void testAliasing()
{
    std::cout << "aliasing (warn only)" << std::endl;

    constexpr double sr = 48000.0;
    constexpr int fftOrder = 15;
    constexpr int fftSize = 1 << fftOrder;   // 32768

    // Land the test tone exactly on an FFT bin so the fundamental does not
    // smear across neighbours and get counted as noise.
    constexpr int toneBin = 683;             // ~1000.5 Hz at 48 kHz
    const double toneHz = sr * toneBin / fftSize;

    for (int quality = 0; quality <= 1; ++quality)
    {
        GjallaAudioProcessor p;
        setParam(p, pid::quality, (float) quality);
        setParam(p, pid::gateOn, 0.0f);
        setParam(p, pid::pedalOn, 0.0f);
        setParam(p, pid::ampGain, 10.0f);
        setParam(p, pid::master, 10.0f);
        setParam(p, pid::bass, 0.0f);
        setParam(p, pid::mid, 0.0f);
        setParam(p, pid::treble, 0.0f);
        setParam(p, pid::presence, 0.0f);
        setParam(p, pid::frost, 0.0f);
        setParam(p, pid::cabMode, (float) (int) choices::CabMode::off);
        p.prepareToPlay(sr, 512);

        const auto rendered = render(p, sr, 512, (fftSize * 2 + 8192) / sr, [toneHz](int i)
        {
            return (float) (0.25 * std::sin(juce::MathConstants<double>::twoPi * toneHz
                                            * (double) i / sr));
        });

        // Skip the first half so the filters and smoothers have settled.
        const int offset = (int) rendered.size() - fftSize;
        std::vector<float> data((size_t) fftSize * 2, 0.0f);
        for (int i = 0; i < fftSize; ++i)
            data[(size_t) i] = rendered[(size_t) (offset + i)];

        juce::dsp::WindowingFunction<float> window((size_t) fftSize,
                                                   juce::dsp::WindowingFunction<float>::hann);
        window.multiplyWithWindowingTable(data.data(), (size_t) fftSize);

        juce::dsp::FFT fft(fftOrder);
        fft.performFrequencyOnlyForwardTransform(data.data());

        float fundamental = 0.0f;
        float worstAudible = 0.0f, worstFullBand = 0.0f;
        int audibleBin = 0, fullBandBin = 0;

        // Above this the measurement stops being about audible aliasing. The
        // top harmonics of a hard-driven amp land in the halfband decimator's
        // transition band, which is centred on Nyquist by construction, and
        // fold back a few hundred Hz below it at whatever attenuation the
        // transition happens to give. That folded energy is real, but it sits
        // at 24 kHz where nothing can hear it, and no halfband design avoids
        // it. So the goal figure is measured below 20 kHz and the full-band
        // number is reported alongside it rather than hidden.
        const int audibleLimitBin = (int) (20000.0 * fftSize / sr);

        for (int bin = 4; bin < fftSize / 2; ++bin)
        {
            const auto magnitude = data[(size_t) bin];

            // within 3 bins of any harmonic of the tone counts as harmonic
            const auto nearest = (bin + toneBin / 2) / toneBin;
            const auto isHarmonic = nearest > 0
                                    && std::abs(bin - nearest * toneBin) <= 3;

            if (isHarmonic)
            {
                if (nearest == 1)
                    fundamental = juce::jmax(fundamental, magnitude);
                continue;
            }

            if (magnitude > worstFullBand)
            {
                worstFullBand = magnitude;
                fullBandBin = bin;
            }
            if (bin <= audibleLimitBin && magnitude > worstAudible)
            {
                worstAudible = magnitude;
                audibleBin = bin;
            }
        }

        const auto toDb = [fundamental](float magnitude)
        {
            return juce::Decibels::gainToDecibels(
                magnitude / juce::jmax(fundamental, 1.0e-12f), -200.0f);
        };

        const auto audibleDb = toDb(worstAudible);
        const auto label = juce::String(quality == 0 ? "Eco (2x)" : "Full (4x)");

        note(label + ": worst non-harmonic component below 20 kHz " + juce::String(audibleDb, 1)
             + " dB relative to the fundamental, at "
             + juce::String(sr * audibleBin / fftSize, 0) + " Hz");
        note(label + ": full band, including the fold around Nyquist, "
             + juce::String(toDb(worstFullBand), 1) + " dB at "
             + juce::String(sr * fullBandBin / fftSize, 0) + " Hz");

        if (quality == 1 && audibleDb > -50.0f)
            std::cout << "WARN: Full quality misses the -50 dB aliasing goal by "
                      << juce::String(audibleDb + 50.0f, 1) << " dB" << std::endl;
    }

    check(true, "aliasing measured (warn only, never fails the build)");
}

void testPresets()
{
    std::cout << "factory presets" << std::endl;

    GjallaAudioProcessor p;
    p.prepareToPlay(48000.0, 256);

    const auto& list = gjalla::presets::all();
    check(list.size() == 6, "six factory presets");

    bool ok = true;
    for (int i = 0; i < (int) list.size(); ++i)
    {
        p.applyPreset(i);

        // every ID a preset mentions must exist, or the preset silently does
        // nothing on that parameter
        for (const auto& [id, value] : list[(size_t) i].values)
            ok = ok && p.apvts.getParameter(id) != nullptr;

        juce::Random rng(i);
        const auto out = render(p, 48000.0, 256, 0.2, [&rng](int) { return rng.nextFloat() - 0.5f; });
        ok = ok && allFinite(out);
    }
    check(ok, "every preset names only real parameters and renders finite output");

    // Applying a preset then another must not leave anything from the first
    // behind: presets start from the defaults.
    p.applyPreset(4);
    const auto boomboxCab = p.apvts.getParameter(pid::cabMode)->getValue();
    p.applyPreset(0);
    p.applyPreset(4);
    check(p.apvts.getParameter(pid::cabMode)->getValue() == boomboxCab,
          "applying presets is order independent");
}

} // namespace

int main()
{
    juce::ScopedJuceInitialiser_GUI juceInit;

    std::cout << "Gjalla tests" << std::endl;

    testNumericalSafety();
    testSilenceInSilenceOut();
    testGateIsClickFree();
    testStateRoundTrip();
    testLatency();
    testCabinetModes();
    testPedalModes();
    testAliasing();
    testPresets();

    std::cout << (failures == 0 ? "all tests passed" : juce::String(failures) + " test(s) failed")
              << std::endl;
    return failures == 0 ? 0 : 1;
}
