// Standalone tests for the plain-value layer: unit strings, plain ranges, monotonicity, the
// exact-preimage requirement at every default, and the read-side-only property of the filter's
// four — no VST3, no REAPER, no framework.

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

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

#include <cmath>
#include <cstdio>
#include <cstring>

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)
#define CHECK_ID(cond, id) do { if(!(cond)) { \
    std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0)

static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
    struct Expect { ParamId id; const char* units; double min; double max; };
    // docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a
    // control silently reclassified into the wrong category fails here.
    const Expect kExpected[] = {
        {kParamKeyTrackPitch,       "%",  0.0,    200.0},
        {kParamRate,                "%",  50.0,   200.0},
        {kParamPitchOffset,         "st", -24.0,  24.0},
        {kParamPitchEnvAttack,      "ms", 0.0,    10000.0},
        {kParamPitchEnvAttackCurve, "",   0.1,    10.0},
        {kParamPitchEnvHold,        "%",  0.0,    100.0},
        {kParamPitchEnvDecay,       "ms", 0.0,    10000.0},
        {kParamPitchEnvDecayCurve,  "",   0.1,    10.0},
        {kParamPitchEnvDepth,       "st", -24.0,  24.0},
        {kParamFilterMorph,         "%",  0.0,    100.0},
        {kParamFilterCutoff,        "Hz", 20.0,   20000.0},
        {kParamFilterQ,             "",   0.1,    10.0},
        {kParamFilterDrive,         "",   0.0,    4.0},
        {kParamFilterModAmount,     "%",  -100.0, 100.0},
        {kParamFilterVelAmount,     "%",  -100.0, 100.0},
        {kParamKeyTrackFilter,      "%",  0.0,    200.0},
        {kParamFilterEnvAttack,      "ms", 0.0,   10000.0},
        {kParamFilterEnvAttackCurve, "",   0.1,   10.0},
        {kParamFilterEnvHold,        "ms", 0.0,   10000.0},
        {kParamFilterEnvDecay,       "ms", 0.0,   10000.0},
        {kParamFilterEnvDecayCurve,  "",   0.1,   10.0},
        {kParamFilterEnvSustain,     "%",  0.0,   100.0},
        {kParamFilterEnvRelease,     "ms", 0.0,   10000.0},
        {kParamFilterEnvReleaseCurve, "",  0.1,   10.0},
        {kParamFilterTrigAttack,      "ms", 0.0,  10000.0},
        {kParamFilterTrigAttackCurve, "",   0.1,  10.0},
        {kParamFilterTrigHold,        "%",  0.0,  100.0},
        {kParamFilterTrigDecay,       "ms", 0.0,  10000.0},
        {kParamFilterTrigDecayCurve,  "",   0.1,  10.0},
        {kParamAmpAttack,          "ms", 0.0,   10000.0},
        {kParamAmpAttackCurve,     "",   0.1,   10.0},
        {kParamAmpHold,            "ms", 0.0,   10000.0},
        {kParamAmpDecay,           "ms", 0.0,   10000.0},
        {kParamAmpDecayCurve,      "",   0.1,   10.0},
        {kParamAmpSustain,         "%",  0.0,   100.0},
        {kParamAmpRelease,         "ms", 0.0,   10000.0},
        {kParamAmpReleaseCurve,    "",   0.1,   10.0},
        {kParamTriggerLength,      "%",  0.0,   100.0},
        {kParamAmpTrigAttack,      "ms", 0.0,   10000.0},
        {kParamAmpTrigAttackCurve, "",   0.1,   10.0},
        {kParamAmpTrigHold,        "%",  0.0,   100.0},
        {kParamAmpTrigDecay,       "ms", 0.0,   10000.0},
        {kParamAmpTrigDecayCurve,  "",   0.1,   10.0},
        {kParamMasterGain,         "dB", -60.0, 24.0},
    };
    const std::size_t count = sizeof(kExpected) / sizeof(kExpected[0]);
    // Every exposed parameter is covered, and nothing else is listed.
    CHECK(count == exposedParams().size());
    for (const Expect& e : kExpected) {
        const ParamRow* row = exposedRowFor(e.id);
        CHECK_ID(row != nullptr, e.id);
        if (!row) continue;
        CHECK_ID(std::strcmp(unitStringFor(row->deck), e.units) == 0, e.id);
        const PlainRange range = plainRangeFor(row->deck);
        // Relative rather than exact: the filter's three endpoints are float constants, so
        // 0.1f widened is not the double 0.1. A wrong RANGE — 0.2, or 20 — still fails.
        CHECK_ID(std::fabs(range.min - e.min) <= std::fabs(e.min) * 1e-6 + 1e-12, e.id);
        CHECK_ID(std::fabs(range.max - e.max) <= std::fabs(e.max) * 1e-6 + 1e-12, e.id);
    }
}

static void testEveryDefaultHasAnExactNormalizedPreimage() {
    // A host's reset-to-default arrives as toPlain(defaultNormalizedValue) and there is no
    // editor-side taper bypass available to it. Exactly equal, not near: a gain landing a hair
    // off unity is an audible error, and a stage time landing a hair off its default is a value
    // the user never dialled.
    for (const ParamRow& row : exposedParams()) {
        const double norm = defaultNormalized(row.deck);
        CHECK_ID(norm >= 0.0 && norm <= 1.0, row.id);
        CHECK_ID(toPlain(row.deck, norm) == defaultPlain(row.deck), row.id);
    }
}

static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() {
    // The criterion is that a host's reset-to-default and the editor's double-click land on the
    // SAME value — and those are different code paths: ParameterInfo::defaultNormalizedValue
    // comes from defaultNormalized (a per-CATEGORY switch), the editor's needle from
    // deckParamNorm (a per-ID one). Asserting the param module against itself would not see the
    // two disagree, and they carry three independently written full scales to disagree about.
    using reasampler::instrument::ui::deckParamNorm;
    const PlaySeconds defaults;
    for (const ParamRow& row : exposedParams()) {
        const double hostNorm = defaultNormalized(row.deck);
        // The four that STORE their normalized position take it verbatim on both surfaces, and
        // the two instance scalars are not in PlaySeconds at all — each read where it lives.
        double editorNorm = 0.0;
        switch (valueHomeFor(row.deck)) {
            case ValueHome::InstanceScalar:
                editorNorm = (row.deck == DeckParam::kMasterGain)
                    ? reasampler::instrument::engine::masterGainNormFromLinear(1.0)  // unity
                    : reasampler::instrument::ui::keyTrackNormFrom(
                          reasampler::instrument::map::InstrumentParams{}.keyTrack);
                break;
            case ValueHome::ParamSetNorm:
            case ValueHome::ParamSet:
                editorNorm = deckParamNorm(row.deck, defaults);
                break;
            case ValueHome::None:
                CHECK_ID(false, row.id);  // an exposed control with no home reads nothing
                continue;
        }
        CHECK_ID(hostNorm == editorNorm, row.id);
    }
}

// The criterion names the editor's DOUBLE-CLICK, and that gesture is resetDeckParam, not
// deckParamNorm over a default-constructed set. Asserted directly: reset a DIALLED set and its
// stored field must read back at exactly the normalized value the host resets to. (The two
// instance scalars have no resetDeckParam entry — the shell resets those from InstrumentParams,
// which the test above covers at the same position.)
static void testADoubleClickResetLandsOnTheHostsDefaultNormalized() {
    using reasampler::instrument::ui::deckParamNorm;
    using reasampler::instrument::ui::resetDeckParam;
    using reasampler::instrument::ui::setDeckParam;
    for (const ParamRow& row : exposedParams()) {
        if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue;
        PlaySeconds dialled;
        // Away from the default first, so a reset that did nothing at all cannot pass.
        setDeckParam(row.deck, dialled, 0.37, /*segment=*/0);
        CHECK_ID(deckParamNorm(row.deck, dialled) != defaultNormalized(row.deck) ||
                     defaultNormalized(row.deck) == 0.37,
                 row.id);
        resetDeckParam(row.deck, dialled);
        CHECK_ID(deckParamNorm(row.deck, dialled) == defaultNormalized(row.deck), row.id);
    }
}

static void testTheFiltersFourTakeTheirStoredNormVerbatim() {
    // Their stored value IS the normalized one, so no taper may participate in their default:
    // this fails the moment someone routes them through toNormalized(toPlain(x)).
    PlaySeconds defaults;
    const DeckParam kStoredNorm[] = {DeckParam::kFilterMorph, DeckParam::kFilterCutoff,
                                     DeckParam::kFilterQ, DeckParam::kFilterDrive};
    for (DeckParam deck : kStoredNorm) {
        CHECK(storesNormalized(deck));
        const float* stored = reasampler::instrument::ui::deckFloatField(deck, defaults);
        CHECK(stored != nullptr);
        if (stored) CHECK(defaultNormalized(deck) == static_cast<double>(*stored));
    }
    // And nothing else claims to store its norm — a control wrongly in that set would silently
    // skip the taper on the reset path.
    for (const ParamRow& row : exposedParams()) {
        const bool listed = row.deck == DeckParam::kFilterMorph ||
                            row.deck == DeckParam::kFilterCutoff ||
                            row.deck == DeckParam::kFilterQ ||
                            row.deck == DeckParam::kFilterDrive;
        CHECK_ID(storesNormalized(row.deck) == listed, row.id);
    }
}

static void testToPlainIsMonotoneAcrossTheWholeTravel() {
    // Monotonicity is required everywhere; round-trip exactness at an arbitrary norm is required
    // NOWHERE and is deliberately not asserted — no log map delivers it in double, and demanding
    // it would rule out the taper the range needs.
    for (const ParamRow& row : exposedParams()) {
        double previous = toPlain(row.deck, 0.0);
        for (int step = 1; step <= 200; ++step) {
            const double plain = toPlain(row.deck, step / 200.0);
            CHECK_ID(plain >= previous, row.id);
            previous = plain;
        }
    }
}

static void testTheEndpointsAreTheDeclaredPlainRange() {
    for (const ParamRow& row : exposedParams()) {
        const PlainRange range = plainRangeFor(row.deck);
        const double top = toPlain(row.deck, 1.0);
        CHECK_ID(std::fabs(top - range.max) <= std::fabs(range.max) * 1e-6 + 1e-9, row.id);
        if (row.deck == DeckParam::kMasterGain) {
            // Norm 0 is TRUE silence, not the -60 dB floor — the one plain value outside the
            // declared range, and the reason the dB formatter has an -inf case at all.
            CHECK(!std::isfinite(toPlain(row.deck, 0.0)));
            continue;
        }
        const double bottom = toPlain(row.deck, 0.0);
        CHECK_ID(std::fabs(bottom - range.min) <= std::fabs(range.min) * 1e-6 + 1e-9, row.id);
    }
}

static void testNoExposedControlIsDiscrete() {
    // This is what makes "stepCount = 0 on all of them" structural rather than lucky: stepCount
    // is only meaningful for a discrete control, and every discrete control is reload or rebuild
    // tier and therefore never reaches the list. UnitCategory::None is the deck's own name for
    // "no continuous unit" — toggles, radios, the curve-popup cells, the integer voice count.
    for (const ParamRow& row : exposedParams()) {
        CHECK_ID(reasampler::instrument::ui::deckParamUnit(row.deck) !=
                     reasampler::instrument::ui::UnitCategory::None,
                 row.id);
    }
}

static void testTheAddedDriveInverseUndoesTheFrozenLaw() {
    using reasampler::instrument::engine::filter::filterDriveDepthFromNorm;
    using reasampler::instrument::engine::filter::filterNormFromDriveDepth;
    for (int step = 0; step <= 100; ++step) {
        const float norm = static_cast<float>(step) / 100.0f;
        const float back = filterNormFromDriveDepth(filterDriveDepthFromNorm(norm));
        CHECK(std::fabs(back - norm) < 1e-6f);
    }
    CHECK(filterNormFromDriveDepth(0.0f) == 0.0f);
    CHECK(filterNormFromDriveDepth(-1.0f) == 0.0f);
    CHECK(filterNormFromDriveDepth(1000.0f) == 1.0f);
}

int main() {
    testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
    testEveryDefaultHasAnExactNormalizedPreimage();
    testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
    testADoubleClickResetLandsOnTheHostsDefaultNormalized();
    testTheFiltersFourTakeTheirStoredNormVerbatim();
    testToPlainIsMonotoneAcrossTheWholeTravel();
    testTheEndpointsAreTheDeclaredPlainRange();
    testNoExposedControlIsDiscrete();
    testTheAddedDriveInverseUndoesTheFrozenLaw();
    if (g_fail == 0) std::printf("param_units: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
