// Standalone tests for the ONE formatter per unit category: the digit shapes each category
// prints, and the property that makes the editor's knob label and the host's parameter string
// identical — both call THIS function, each over the plain value ITS OWN surface derives (the
// editor's exponent read goes to the stored field, not through the knob law). No VST3, no
// REAPER, no framework.

#include "../src/core/instrument/param/param_format.h"

#include "../src/core/instrument/engine/master_gain.h"
#include "../src/core/instrument/map/sample_map.h"
#include "../src/core/instrument/param/param_id.h"
#include "../src/core/instrument/param/param_units.h"
#include "../src/core/instrument/ui/deck_values.h"

#include <cmath>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>

using namespace reasampler;
using namespace reasampler::instrument::param;
using reasampler::instrument::map::PlaySeconds;
using reasampler::instrument::ui::DeckParam;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

static std::string digits(UnitKind kind, double plain) {
    char buf[24];
    formatPlain(kind, plain, buf, sizeof(buf));
    return std::string(buf);
}

static void testEachCategoryPrintsItsSpecifiedShape() {
    // A time constant never switches to seconds, so the ceiling reads 10000 and not 10.
    CHECK(digits(UnitKind::Time, 0.5) == "0.5");
    CHECK(digits(UnitKind::Time, 3.0) == "3.0");
    CHECK(digits(UnitKind::Time, 10.0) == "10");
    CHECK(digits(UnitKind::Time, 104.0) == "104");
    CHECK(digits(UnitKind::Time, 10000.0) == "10000");
    // Semitones are always signed, including at zero — an unsigned "0.0" beside a "+3.5" reads
    // as a different kind of quantity.
    CHECK(digits(UnitKind::Semitones, 3.5) == "+3.5");
    CHECK(digits(UnitKind::Semitones, -12.0) == "-12.0");
    CHECK(digits(UnitKind::Semitones, 0.0) == "+0.0");
    CHECK(digits(UnitKind::PercentUnipolar, 100.0) == "100");
    CHECK(digits(UnitKind::PercentKeyTrack, 200.0) == "200");
    CHECK(digits(UnitKind::PercentBipolar, -40.0) == "-40");
    CHECK(digits(UnitKind::PercentBipolar, 40.0) == "+40");
    // Rate keeps a decimal: its snap grid is whole semitones, which do not land on integer
    // percent, so an integer display would print a snapped position the snap cannot produce.
    CHECK(digits(UnitKind::PercentRate, 105.946) == "105.9");
    CHECK(digits(UnitKind::PercentRate, 200.0) == "200.0");
    CHECK(digits(UnitKind::Decibels, 0.0) == "+0.0");
    CHECK(digits(UnitKind::Decibels, -12.0) == "-12.0");
    CHECK(digits(UnitKind::Decibels, -std::numeric_limits<double>::infinity()) == "-inf");
    // Cutoff's "k" abbreviation is retired — one static units string cannot switch with
    // magnitude, and keeping "12.8k" on one surface alone is the divergence this file forbids.
    CHECK(digits(UnitKind::Hertz, 240.0) == "240");
    CHECK(digits(UnitKind::Hertz, 12800.0) == "12800");
    // The caret on a curve dial is the editor's static cell chrome, never part of the value.
    CHECK(digits(UnitKind::Dimensionless, 1.0) == "1.00");
    CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10");
}

// The EDITOR's derivation, spelled the way ReaSamplerEditor::deckPlainValue spells it — a curve
// exponent is read off its stored field, never round-tripped through the knob law. Driving the
// sweep through this rather than through toPlain(deckParamNorm(...)) is what makes the exponent
// half of it a real assertion instead of a round trip on both sides.
static double editorPlainValue(DeckParam deck, PlaySeconds& play) {
    using reasampler::instrument::ui::UnitCategory;
    if (reasampler::instrument::ui::deckParamUnit(deck) == UnitCategory::Exponent) {
        const double* stored = reasampler::instrument::ui::deckDoubleField(deck, play);
        return stored ? *stored : 0.0;
    }
    return toPlain(deck, reasampler::instrument::ui::deckParamNorm(deck, play));
}

static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
    // The host derives its plain value from the normalized one it holds; the editor derives its
    // from the STORED field. If those two derivations disagreed at any reachable value the two
    // surfaces would print different numbers for one control — this is that property, swept over
    // the whole travel of every exposed control the parameter set carries.
    for (const ParamRow& row : exposedParams()) {
        if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue;  // own tests below
        for (int step = 0; step <= 40; ++step) {
            const double norm = step / 40.0;
            PlaySeconds play;
            reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0);
            const double storedNorm = reasampler::instrument::ui::deckParamNorm(row.deck, play);

            if (row.deck == DeckParam::kFilterMorph) {
                // MORPH ALONE: its toPlain is full-double (`clamp01(n) * 100`), so the float the
                // model stores and the double the host holds are genuinely different inputs, and
                // at a value landing on a display rounding boundary that is worth one integer
                // percent. Cutoff/Q/drive cast to float INSIDE toPlain, so they are bit-identical
                // either way and are held to the string below like everything else.
                const double hostPlain = toPlain(row.deck, norm);
                const double editorPlain = toPlain(row.deck, storedNorm);
                const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9;
                if (std::fabs(hostPlain - editorPlain) > tolerance) {
                    std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n",
                                row.id, norm, hostPlain, editorPlain);
                    ++g_fail;
                }
                continue;
            }
            // A write the model CLAMPED (Trigger length's (0,1] floor) is not a settled state:
            // setParamNormalized caches what the model TOOK, so the host never holds the rejected
            // value. Only the four float-stored positions differ by a ulp rather than a clamp.
            if (!storesNormalized(row.deck) && storedNorm != norm) continue;

            char hostBuf[24];
            char editorBuf[24];
            formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
            formatPlainFor(row.deck, editorPlainValue(row.deck, play), editorBuf,
                           sizeof(editorBuf));
            if (std::strcmp(hostBuf, editorBuf) != 0) {
                std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
                            row.id, norm, hostBuf, editorBuf);
                ++g_fail;
            }
        }
    }
}

// A continuous range stays continuous at the host boundary (Daniel, 2026-08-02): the knob
// detent is a drag affordance only, so an exponent inside it but not exactly neutral — reachable
// via an overlay knot drag — must read back true to the host, the same digits the editor shows.
static void testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor() {
    PlaySeconds play;
    // The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is
    // inside it and still prints as a distinct number.
    play.adsr.attackCurve = 1.04;
    char editorBuf[24];
    formatPlainFor(DeckParam::kAttackCurve, editorPlainValue(DeckParam::kAttackCurve, play),
                   editorBuf, sizeof(editorBuf));
    CHECK(std::string(editorBuf) == "1.04");
    const double hostNorm =
        reasampler::instrument::ui::deckParamNorm(DeckParam::kAttackCurve, play);
    char hostBuf[24];
    formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf,
                   sizeof(hostBuf));
    CHECK(std::string(hostBuf) == "1.04");
}

static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() {
    using reasampler::instrument::map::InstrumentParams;
    for (int step = 0; step <= 40; ++step) {
        const double norm = step / 40.0;
        InstrumentParams params;
        params.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
        const double editorNorm = reasampler::instrument::ui::keyTrackNormFrom(params.keyTrack);
        char hostBuf[24];
        char editorBuf[24];
        formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, norm), hostBuf,
                       sizeof(hostBuf));
        formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, editorNorm), editorBuf,
                       sizeof(editorBuf));
        if (std::strcmp(hostBuf, editorBuf) != 0) {
            std::printf("FAIL key-track at norm %.4f: host \"%s\" vs editor \"%s\"\n",
                        norm, hostBuf, editorBuf);
            ++g_fail;
        }
    }
}

static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
    using reasampler::instrument::engine::masterGainLinearFromNorm;
    using reasampler::instrument::engine::masterGainNormFromLinear;
    for (int step = 0; step <= 40; ++step) {
        const double norm = step / 40.0;
        // The editor reads the processor's stored LINEAR gain back through the taper; the host
        // holds the normalized value directly.
        const double editorNorm = masterGainNormFromLinear(masterGainLinearFromNorm(norm));
        char hostBuf[24];
        char editorBuf[24];
        formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, norm),
                       hostBuf, sizeof(hostBuf));
        formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, editorNorm),
                       editorBuf, sizeof(editorBuf));
        if (std::strcmp(hostBuf, editorBuf) != 0) {
            std::printf("FAIL master gain at norm %.4f: host \"%s\" vs editor \"%s\"\n",
                        norm, hostBuf, editorBuf);
            ++g_fail;
        }
    }
}

static void testTypingBackADisplayedValueLandsOnIt() {
    // getParamValueByString's half: the digits the host just showed must parse to the same
    // plain value, with or without the unit a user may retype beside them.
    double plain = 0.0;
    CHECK(parsePlain(UnitKind::Time, "104", plain) && plain == 104.0);
    CHECK(parsePlain(UnitKind::Time, "104 ms", plain) && plain == 104.0);
    CHECK(parsePlain(UnitKind::Semitones, "+3.5", plain) && plain == 3.5);
    CHECK(parsePlain(UnitKind::Semitones, "-12.0st", plain) && plain == -12.0);
    CHECK(parsePlain(UnitKind::Hertz, "12800Hz", plain) && plain == 12800.0);
    CHECK(parsePlain(UnitKind::Decibels, "-inf", plain) && !std::isfinite(plain) && plain < 0.0);
    CHECK(!parsePlain(UnitKind::Time, "abc", plain));
    CHECK(!parsePlain(UnitKind::Time, nullptr, plain));
}

static void testAShortBufferIsNeverOverrunAndAlwaysTerminates() {
    for (int kind = 0; kind <= static_cast<int>(UnitKind::Dimensionless); ++kind) {
        char tiny[4];
        std::memset(tiny, 'x', sizeof(tiny));
        formatPlain(static_cast<UnitKind>(kind), 1500.0, tiny, sizeof(tiny));
        CHECK(tiny[3] == '\0');
    }
}

static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
    for (const ParamRow& row : exposedParams()) {
        char buf[24];
        formatPlainFor(row.deck, toPlain(row.deck, 0.5), buf, sizeof(buf));
        if (buf[0] == '\0') {
            std::printf("FAIL param %u produced an empty string\n", row.id);
            ++g_fail;
        }
    }
}

int main() {
    testEachCategoryPrintsItsSpecifiedShape();
    testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
    testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor();
    testMasterGainPrintsTheSameDigitsFromEitherSurface();
    testKeyTrackPrintsTheSameDigitsFromEitherSurface();
    testTypingBackADisplayedValueLandsOnIt();
    testAShortBufferIsNeverOverrunAndAlwaysTerminates();
    testEveryExposedParameterHasAFormatterThatWritesSomething();
    if (g_fail == 0) std::printf("param_format: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
