// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Andrea De Murtas

#include "PluginProcessor.h"
#include "PluginEditor.h"
#include "Presets.h"

GjallaAudioProcessor::GjallaAudioProcessor()
    : AudioProcessor(BusesProperties()
                         .withInput("Input", juce::AudioChannelSet::stereo(), true)
                         .withOutput("Output", juce::AudioChannelSet::stereo(), true)),
      apvts(*this, &undoManager, "PARAMETERS", createGjallaParameterLayout())
{
    // Cached once: the audio thread must not do a hashed string lookup per
    // block, and these pointers are stable for the life of the APVTS.
    raw.inTrim     = apvts.getRawParameterValue(pid::inTrim);
    raw.gateOn     = apvts.getRawParameterValue(pid::gateOn);
    raw.gateThresh = apvts.getRawParameterValue(pid::gateThresh);
    raw.pedalOn    = apvts.getRawParameterValue(pid::pedalOn);
    raw.pedalMode  = apvts.getRawParameterValue(pid::pedalMode);
    raw.pedalDrive = apvts.getRawParameterValue(pid::pedalDrive);
    raw.pedalTone  = apvts.getRawParameterValue(pid::pedalTone);
    raw.pedalLevel = apvts.getRawParameterValue(pid::pedalLevel);
    raw.ampGain    = apvts.getRawParameterValue(pid::ampGain);
    raw.tight      = apvts.getRawParameterValue(pid::tight);
    raw.bass       = apvts.getRawParameterValue(pid::bass);
    raw.mid        = apvts.getRawParameterValue(pid::mid);
    raw.treble     = apvts.getRawParameterValue(pid::treble);
    raw.presence   = apvts.getRawParameterValue(pid::presence);
    raw.master     = apvts.getRawParameterValue(pid::master);
    raw.cabMode    = apvts.getRawParameterValue(pid::cabMode);
    raw.frost      = apvts.getRawParameterValue(pid::frost);
    raw.outTrim    = apvts.getRawParameterValue(pid::outTrim);
    raw.quality    = apvts.getRawParameterValue(pid::quality);

    // any parameter edit marks the current preset as modified
    for (auto* p : getParameters())
        if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(p))
            apvts.addParameterListener(rp->paramID, this);
}

GjallaAudioProcessor::~GjallaAudioProcessor()
{
    for (auto* p : getParameters())
        if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(p))
            apvts.removeParameterListener(rp->paramID, this);
}

juce::AudioProcessorValueTreeState::ParameterLayout createGjallaParameterLayout()
{
    using namespace juce;

    const auto decibels = AudioParameterFloatAttributes()
        .withStringFromValueFunction([](float value, int)
        {
            return String(value, 1) + " dB";
        });

    // Branch on the value rounded to the printed precision, so a formatted
    // string parses back to a value that formats identically (hosts check).
    const auto hertz = AudioParameterFloatAttributes()
        .withStringFromValueFunction([](float value, int)
        {
            if (std::round(value) >= 1000.0f) return String(value / 1000.0f, 2) + " kHz";
            if (value >= 99.995f)             return String(roundToInt(value)) + " Hz";
            return String(value, 2) + " Hz";
        })
        .withValueFromStringFunction([](const String& text)
        {
            const auto value = text.getFloatValue();
            return text.containsIgnoreCase("k") ? value * 1000.0f : value;
        });

    // The amp's own knobs are unitless 0..10, like the markings on a front
    // panel: what +7 dB of preamp gain means is not something anyone dials in.
    const auto knob = AudioParameterFloatAttributes()
        .withStringFromValueFunction([](float value, int)
        {
            return String(value, 1);
        });

    AudioProcessorValueTreeState::ParameterLayout layout;

    auto add = [&layout](auto param) { layout.add(std::move(param)); };

    // INPUT
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::inTrim, 1 }, "Input Trim",
        NormalisableRange<float>(-24.0f, 24.0f), 0.0f, decibels));

    // GATE
    add(std::make_unique<AudioParameterBool>(ParameterID { pid::gateOn, 1 }, "Gate", true));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::gateThresh, 1 }, "Gate Threshold",
        NormalisableRange<float>(-80.0f, -20.0f), -50.0f, decibels));

    // PEDAL
    add(std::make_unique<AudioParameterBool>(ParameterID { pid::pedalOn, 1 }, "Pedal", true));
    add(std::make_unique<AudioParameterChoice>(ParameterID { pid::pedalMode, 1 }, "Pedal Mode",
        choices::pedalModes, (int) choices::PedalMode::dist));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::pedalDrive, 1 }, "Pedal Drive",
        NormalisableRange<float>(0.0f, 10.0f), 6.0f, knob));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::pedalTone, 1 }, "Pedal Tone",
        NormalisableRange<float>(0.0f, 10.0f), 5.0f, knob));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::pedalLevel, 1 }, "Pedal Level",
        NormalisableRange<float>(-12.0f, 12.0f), 0.0f, decibels));

    // AMP
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::ampGain, 1 }, "Gain",
        NormalisableRange<float>(0.0f, 10.0f), 6.0f, knob));

    // Skewed to its geometric centre, so the useful 60-120 Hz region where a
    // tight control actually does something sits under the middle of the sweep.
    auto tightRange = NormalisableRange<float>(20.0f, 300.0f);
    tightRange.setSkewForCentre(std::sqrt(20.0f * 300.0f));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::tight, 1 }, "Tight",
        tightRange, 90.0f, hertz));

    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::bass, 1 }, "Bass",
        NormalisableRange<float>(-12.0f, 12.0f), 0.0f, decibels));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::mid, 1 }, "Mid",
        NormalisableRange<float>(-12.0f, 12.0f), 0.0f, decibels));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::treble, 1 }, "Treble",
        NormalisableRange<float>(-12.0f, 12.0f), 2.0f, decibels));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::presence, 1 }, "Presence",
        NormalisableRange<float>(-9.0f, 9.0f), 2.0f, decibels));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::master, 1 }, "Master",
        NormalisableRange<float>(0.0f, 10.0f), 3.0f, knob));

    // CAB AND OUTPUT
    add(std::make_unique<AudioParameterChoice>(ParameterID { pid::cabMode, 1 }, "Cabinet",
        choices::cabModes, (int) choices::CabMode::w412));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::frost, 1 }, "Frost",
        NormalisableRange<float>(-6.0f, 6.0f), 1.0f, decibels));
    add(std::make_unique<AudioParameterFloat>(ParameterID { pid::outTrim, 1 }, "Output Trim",
        NormalisableRange<float>(-24.0f, 24.0f), 0.0f, decibels));
    add(std::make_unique<AudioParameterChoice>(ParameterID { pid::quality, 1 }, "Quality",
        choices::qualities, (int) choices::Quality::full));

    return layout;
}

// Mono or stereo in, stereo out. Mono->mono is also accepted for hosts that
// run effects on mono tracks; stereo->mono is not.
bool GjallaAudioProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const
{
    const auto in  = layouts.getMainInputChannelSet();
    const auto out = layouts.getMainOutputChannelSet();

    if (in != juce::AudioChannelSet::mono() && in != juce::AudioChannelSet::stereo())
        return false;
    if (out != juce::AudioChannelSet::mono() && out != juce::AudioChannelSet::stereo())
        return false;
    return out != juce::AudioChannelSet::mono() || in == juce::AudioChannelSet::mono();
}

void GjallaAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
    srHz = sampleRate;
    preparedBlockSize = juce::jmax(1, samplesPerBlock);
    preparedChannels = juce::jlimit(1, 2, juce::jmax(getTotalNumInputChannels(),
                                                     getTotalNumOutputChannels()));

    // Everything that can allocate happens here and nowhere else.
    osEco = std::make_unique<juce::dsp::Oversampling<float>>(
        (size_t) preparedChannels, 1,
        juce::dsp::Oversampling<float>::filterHalfBandPolyphaseIIR, true, true);
    osFull = std::make_unique<juce::dsp::Oversampling<float>>(
        (size_t) preparedChannels, 2,
        juce::dsp::Oversampling<float>::filterHalfBandPolyphaseIIR, true, true);

    osEco->initProcessing((size_t) preparedBlockSize);
    osFull->initProcessing((size_t) preparedBlockSize);
    osEco->reset();
    osFull->reset();

    // Both stages are asked for whole-sample latency, so the figure the host
    // gets is exact rather than rounded. They still differ from each other, so
    // the shorter one is padded out to the longer.
    reportedLatency = juce::jmax(juce::roundToInt(osEco->getLatencyInSamples()),
                                 juce::roundToInt(osFull->getLatencyInSamples()));

    const juce::dsp::ProcessSpec spec { sampleRate, (juce::uint32) preparedBlockSize,
                                        (juce::uint32) preparedChannels };
    latencyPad.prepare(spec);
    latencyPad.setMaximumDelayInSamples(juce::jmax(1, reportedLatency) + 1);
    latencyPad.reset();

    // The oversampled stages are prepared at the highest rate they can ever
    // see; each block then tells them which rate is actually in force.
    const auto maxOsRate = sampleRate * 4.0;

    gate.prepare(sampleRate, preparedChannels);
    pedal.prepare(maxOsRate, preparedChannels);
    preamp.prepare(maxOsRate, preparedChannels);
    toneStack.prepare(preparedChannels);
    powerAmp.prepare(preparedChannels);
    presenceStage.prepare(preparedChannels);
    cabinet.prepare(sampleRate, preparedBlockSize, preparedChannels);
    frostTilt.prepare(sampleRate, 1000.0f);
    outputDcBlocker.prepare(sampleRate, 10.0f);
    clickGuard.prepare(sampleRate);

    inTrimSm.reset(sampleRate, 0.02);
    outTrimSm.reset(sampleRate, 0.02);
    frostLoSm.reset(sampleRate, 0.02);
    frostHiSm.reset(sampleRate, 0.02);

    inTrimSm.setCurrentAndTargetValue(juce::Decibels::decibelsToGain(raw.inTrim->load()));
    outTrimSm.setCurrentAndTargetValue(juce::Decibels::decibelsToGain(raw.outTrim->load()));
    frostLoSm.setCurrentAndTargetValue(gjalla::TiltEq::lowGain(raw.frost->load()));
    frostHiSm.setCurrentAndTargetValue(gjalla::TiltEq::highGain(raw.frost->load()));

    const std::pair<juce::SmoothedValue<float>*, std::atomic<float>*> blockSmoothers[] {
        { &tightSm, raw.tight },           { &bassSm, raw.bass },
        { &midSm, raw.mid },               { &trebleSm, raw.treble },
        { &presenceSm, raw.presence },     { &pedalDriveSm, raw.pedalDrive },
        { &pedalToneSm, raw.pedalTone },   { &ampGainSm, raw.ampGain },
    };
    for (auto& [sm, value] : blockSmoothers)
    {
        sm->reset(sampleRate, 0.05);
        sm->setCurrentAndTargetValue(value->load());
    }

    // Adopt the current settings without a fade: there is nothing to click.
    activeQuality = (int) raw.quality->load();
    activePedalMode = (int) raw.pedalMode->load();
    activeCabMode = (int) raw.cabMode->load();

    updateLatency();
}

// The oversampling filters, and nothing else: the gate has no lookahead and
// the convolution engine is the zero-latency one (asserted by the tests).
void GjallaAudioProcessor::updateLatency()
{
    if (getLatencySamples() != reportedLatency)
        setLatencySamples(reportedLatency);
}

// Clears every filter state in the chain. Audio thread safe: none of these
// reset() calls allocate, they only zero buffers that already exist.
void GjallaAudioProcessor::resetChain(bool includeCabinet) noexcept
{
    pedal.reset();
    preamp.reset();
    toneStack.reset();
    powerAmp.reset();
    presenceStage.reset();
    frostTilt.reset();
    outputDcBlocker.reset();
    latencyPad.reset();

    if (osEco != nullptr)
        osEco->reset();
    if (osFull != nullptr)
        osFull->reset();

    if (includeCabinet)
        cabinet.reset();
}

juce::dsp::Oversampling<float>& GjallaAudioProcessor::activeOversampling() noexcept
{
    return activeQuality == (int) choices::Quality::eco ? *osEco : *osFull;
}

double GjallaAudioProcessor::oversampledRate() const noexcept
{
    return srHz * (activeQuality == (int) choices::Quality::eco ? 2.0 : 4.0);
}

void GjallaAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
    juce::ScopedNoDenormals noDenormals;

    const auto numSamples = buffer.getNumSamples();
    const auto numIn = getTotalNumInputChannels();
    const auto numOut = getTotalNumOutputChannels();

    if (numSamples <= 0 || buffer.getNumChannels() <= 0)
        return;

    // Mono in, stereo out: duplicate rather than leave the extra channel
    // holding whatever the host had in it.
    for (int ch = numIn; ch < numOut; ++ch)
        buffer.copyFrom(ch, 0, buffer, 0, 0, numSamples);

    const auto nCh = juce::jmin(buffer.getNumChannels(), preparedChannels);
    if (nCh <= 0 || osEco == nullptr || osFull == nullptr)
        return;

    meters.inPeak.store(buffer.getMagnitude(0, numSamples));

    // --- settings that cannot change under the signal ----------------------
    const auto quality = (int) raw.quality->load();
    const auto pedalMode = (int) raw.pedalMode->load();
    const auto cabMode = (int) raw.cabMode->load();

    if (quality != activeQuality || pedalMode != activePedalMode || cabMode != activeCabMode)
        clickGuard.requestSwitch();

    if (clickGuard.consumeSwitch())
    {
        activeQuality = quality;
        activePedalMode = pedalMode;
        activeCabMode = cabMode;

        // The output is already at silence, so clearing every filter state is
        // free; leaving it would ring the old settings into the new ones.
        resetChain(true);
    }

    const auto padSamples = reportedLatency
                            - juce::roundToInt(activeOversampling().getLatencyInSamples());

    // --- input trim --------------------------------------------------------
    inTrimSm.setTargetValue(juce::Decibels::decibelsToGain(raw.inTrim->load()));
    for (int i = 0; i < numSamples; ++i)
    {
        const auto g = inTrimSm.getNextValue();
        for (int ch = 0; ch < nCh; ++ch)
            buffer.setSample(ch, i, buffer.getSample(ch, i) * g);
    }

    // --- gate, on the clean signal ahead of every gain stage ----------------
    meters.gateGR.store(gate.process(buffer, raw.gateThresh->load(),
                                     raw.gateOn->load() > 0.5f));

    // --- oversampled nonlinear path ----------------------------------------
    {
        juce::dsp::AudioBlock<float> block(buffer.getArrayOfWritePointers(),
                                           (size_t) nCh, (size_t) numSamples);
        auto& os = activeOversampling();
        auto osBlock = os.processSamplesUp(block);
        const auto osRate = oversampledRate();

        // Block-rate smoothing (see the header). Stepped even while the pedal
        // is bypassed, so switching it back on lands on the settled values.
        auto smoothed = [numSamples](juce::SmoothedValue<float>& sm, float target)
        {
            sm.setTargetValue(target);
            return sm.skip(numSamples);
        };
        const auto pedalDrive = smoothed(pedalDriveSm, raw.pedalDrive->load());
        const auto pedalTone = smoothed(pedalToneSm, raw.pedalTone->load());

        if (raw.pedalOn->load() > 0.5f)
        {
            const gjalla::Pedal::Settings ps {
                activePedalMode, pedalDrive, pedalTone, raw.pedalLevel->load() };
            pedal.process(osBlock, ps, osRate);
        }

        const gjalla::Preamp::Settings ams { smoothed(ampGainSm, raw.ampGain->load()),
                                             smoothed(tightSm, raw.tight->load()) };
        preamp.process(osBlock, ams, osRate);

        const gjalla::ToneStack::Settings ts { smoothed(bassSm, raw.bass->load()),
                                               smoothed(midSm, raw.mid->load()),
                                               smoothed(trebleSm, raw.treble->load()) };
        toneStack.process(osBlock, ts, osRate);

        powerAmp.process(osBlock, raw.master->load(), osRate);

        os.processSamplesDown(block);
    }

    // --- base rate ---------------------------------------------------------
    presenceSm.setTargetValue(raw.presence->load());
    presenceStage.process(buffer, presenceSm.skip(numSamples), srHz);
    cabinet.process(buffer, activeCabMode);

    const auto frostDb = raw.frost->load();
    frostLoSm.setTargetValue(gjalla::TiltEq::lowGain(frostDb));
    frostHiSm.setTargetValue(gjalla::TiltEq::highGain(frostDb));
    outTrimSm.setTargetValue(juce::Decibels::decibelsToGain(raw.outTrim->load()));

    for (int i = 0; i < numSamples; ++i)
    {
        const auto gLo = frostLoSm.getNextValue();
        const auto gHi = frostHiSm.getNextValue();
        const auto out = outTrimSm.getNextValue();
        const auto guard = clickGuard.nextGain();

        for (int ch = 0; ch < nCh; ++ch)
        {
            auto x = frostTilt.processSample(ch, buffer.getSample(ch, i), gLo, gHi);
            x = outputDcBlocker.processSample(ch, x) * out * guard;

            if (padSamples > 0)
            {
                latencyPad.pushSample(ch, x);
                x = latencyPad.popSample(ch, (float) padSamples, true);
            }

            buffer.setSample(ch, i, x);
        }
    }

    // Anything the host handed us beyond the channels we process (it cannot
    // happen with the supported layouts, but a buffer is a buffer).
    for (int ch = nCh; ch < buffer.getNumChannels(); ++ch)
        buffer.clear(ch, 0, numSamples);

    meters.outPeak.store(buffer.getMagnitude(0, numSamples));
}

void GjallaAudioProcessor::getStateInformation(juce::MemoryBlock& destData)
{
    juce::MemoryOutputStream stream(destData, false);
    capturePresetState().writeToStream(stream);
}

void GjallaAudioProcessor::setStateInformation(const void* data, int sizeInBytes)
{
    if (auto tree = juce::ValueTree::readFromData(data, (size_t) sizeInBytes); tree.isValid())
        applyStateTree(tree);
}

juce::ValueTree GjallaAudioProcessor::capturePresetState()
{
    // Refresh the copy from the live parameter values: the APVTS tree cache
    // only syncs through listener callbacks, and a host may set parameters
    // from the audio thread right before saving (the CLAP wrapper does).
    auto tree = apvts.copyState();
    for (auto* p : getParameters())
        if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(p))
            if (auto child = tree.getChildWithProperty("id", rp->paramID); child.isValid())
                child.setProperty("value", rp->convertFrom0to1(rp->getValue()), nullptr);

    tree.setProperty("stateVersion", stateVersion, nullptr);
    return tree;
}

// Migrations for states written by older builds. The hook exists from the
// first commit so future parameter renames or range changes never break
// saved sessions or presets.
void GjallaAudioProcessor::migrateState(juce::ValueTree& state, int fromVersion)
{
    juce::ignoreUnused(state, fromVersion);
}

void GjallaAudioProcessor::applyStateTree(juce::ValueTree tree)
{
    if (! tree.isValid())
        return;

    migrateState(tree, (int) tree.getProperty("stateVersion", 0));
    apvts.replaceState(tree);

    // The IR path travels with the state, so restoring a session has to put
    // the file back through the loader, and a state without one has to unload
    // whatever the previous state had, or the old file keeps convolving.
    const auto path = apvts.state.getProperty(prop::irPath).toString();
    const juce::File file(path);
    if (path.isNotEmpty() && file.existsAsFile())
        cabinet.loadIr(file);
    else
        cabinet.clearIr();

    presetDirty.store(false);
    undoManager.clearUndoHistory();
}

void GjallaAudioProcessor::loadIrFile(const juce::File& file)
{
    if (! file.existsAsFile())
        return;

    cabinet.loadIr(file);
    apvts.state.setProperty(prop::irPath, file.getFullPathName(), nullptr);
}

void GjallaAudioProcessor::clearIr()
{
    cabinet.clearIr();
    apvts.state.setProperty(prop::irPath, juce::String(), nullptr);
}

juce::File GjallaAudioProcessor::getIrFile() const
{
    const auto path = apvts.state.getProperty(prop::irPath).toString();
    return path.isEmpty() ? juce::File() : juce::File(path);
}

void GjallaAudioProcessor::applyPreset(int index)
{
    const auto& list = gjalla::presets::all();
    if (! juce::isPositiveAndBelow(index, (int) list.size()))
        return;

    // Every preset starts from the defaults, so a preset that does not mention
    // a parameter always lands on the same value.
    for (auto* p : getParameters())
        if (auto* rp = dynamic_cast<juce::RangedAudioParameter*>(p))
            rp->setValueNotifyingHost(rp->getDefaultValue());

    for (const auto& [id, value] : list[(size_t) index].values)
        if (auto* rp = apvts.getParameter(id))
            rp->setValueNotifyingHost(rp->convertTo0to1((float) value));

    apvts.state.setProperty(prop::presetName, list[(size_t) index].name, nullptr);
    presetDirty.store(false);
}

juce::AudioProcessorEditor* GjallaAudioProcessor::createEditor()
{
    return new GjallaAudioProcessorEditor(*this);
}

// This creates new instances of the plugin.
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
    return new GjallaAudioProcessor();
}
