// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: layoutStrip (normal, degenerate, sub-key-width); same-class key-width uniformity
// swept across editor client widths (including multiples of them, standing in for larger
// client sizes — see the client-pixel-only note below); the tiled key area staying centred
// inside a band that spans the full width it was handed; whiteIndexOf / isNaturalKey across
// octave boundaries and the 0..127 extremes; keyRect tiling and black-over-white overlap;
// keyAtPoint resolving black-over-white by zone and missing off-band; the root affordance's
// hit-to-marker round trip; resolveDragNote clamping a wandering pointer; noteName under the
// C4 (MIDI 60) DAW convention; the gutter at the sawtooth's maximum residue; and the gutter
// pinned at the shipped default window size (derived from kEditorMinWidth/kEditorMinHeight).
//
// Client-pixel-only guarantee: every width swept below is a CLIENT-pixel width. Nothing in
// the instrument implements IPlugViewContentScaleSupport, so if a host scales the plugin
// window itself, uniform integer key widths get resampled at the physical-pixel level —
// unverified by this suite (see src/core/instrument/CLAUDE.md).

#include "../src/core/instrument/ui/keyboard_strip.h"
#include "../src/core/instrument/ui/sample_bands.h"   // computeSampleBands, to derive the CHROME
                                                       // band the shipped default window (840x620,
                                                       // editor_session.cpp) hands the strip
#include "../src/core/instrument/ui/sample_chrome.h"  // chromeRects, to derive rootStrip's width
                                                       // the same way the shell does

#include <cstdio>
#include <string>

using namespace reasampler;
using namespace reasampler::instrument::ui;

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

// A comfortable strip: wide enough that every class is several pixels across.
static StripLayout wideStrip() { return layoutStrip(1280, 30); }

// The widths a real editor hands the strip — the 560px minimum client up to a wide window.
static const int kBaseWidths[] = {544, 600, 640, 700, 749, 750, 751, 824, 900, 1000,
                                  1024, 1103, 1264, 1600, 1920, 2400};
// Multiplies each base width to widen client-pixel coverage (e.g. a maximized/larger client
// area) — NOT a host DPI/content-scale factor; see the client-pixel-only note above.
static const double kWidthMultipliers[] = {1.0, 1.25, 1.5, 1.75, 2.0};

// --- layoutStrip --------------------------------------------------------------

static void testLayoutFillsTheBandAndCentresTheKeys() {
    const StripLayout L = layoutStrip(824, 30);
    CHECK(L.band == Rect::ltrb(0, 0, 824, 30));
    CHECK(L.whiteWidth == 824 / kStripWhiteKeyCount);
    CHECK(L.keys.width == L.whiteWidth * kStripWhiteKeyCount);
    CHECK(L.keys.y == 0 && L.keys.height == 30);
    // The residue an indivisible width leaves splits evenly between the two end margins.
    const int leftMargin = L.keys.x - L.band.x;
    const int rightMargin = L.band.right() - L.keys.right();
    CHECK(leftMargin >= 0 && rightMargin >= 0);
    CHECK(rightMargin - leftMargin >= 0 && rightMargin - leftMargin <= 1);
}

static void testDegenerateSizesYieldNoKeys() {
    const StripLayout zero = layoutStrip(0, 0);
    CHECK(zero.band.empty());
    CHECK(zero.keys.empty());
    CHECK(keyAtPoint(zero, 0, 0) == -1);
    CHECK(keyRect(zero, 60).empty());
    CHECK(resolveDragNote(zero, 5, 5) == -1);

    // Narrower than one pixel per white key: no keys at all, but the band still reports its
    // size so the caller can draw the empty surface.
    const StripLayout narrow = layoutStrip(kStripWhiteKeyCount - 1, 30);
    CHECK(narrow.band.width == kStripWhiteKeyCount - 1);
    CHECK(narrow.keys.empty());
    CHECK(keyAtPoint(narrow, 10, 10) == -1);
}

// --- the sharp one: same-class widths are uniform at every client width tested ---

static void testSameClassKeysAreEqualWidthAcrossClientWidths() {
    for (const int base : kBaseWidths) {
        for (const double scale : kWidthMultipliers) {
            const int w = static_cast<int>(base * scale);
            const int h = static_cast<int>(30 * scale);
            const StripLayout L = layoutStrip(w, h);
            if (L.keys.empty()) continue;  // covered by the degenerate test

            int whiteW = -1;
            int blackW = -1;
            int blackH = -1;
            for (int n = 0; n < kStripKeyCount; ++n) {
                const Rect k = keyRect(L, n);
                CHECK(!k.empty());
                if (isNaturalKey(n)) {
                    if (whiteW < 0) whiteW = k.width;
                    CHECK(k.width == whiteW);
                    CHECK(k.height == L.keys.height);  // whites run the full band height
                } else {
                    if (blackW < 0) { blackW = k.width; blackH = k.height; }
                    CHECK(k.width == blackW);
                    CHECK(k.height == blackH);
                }
            }
            CHECK(whiteW == L.whiteWidth);
            CHECK(blackW == L.blackWidth);
            CHECK(blackH == L.blackHeight);
            CHECK(blackW < whiteW);  // the two classes stay visually distinct
            CHECK(blackH < L.keys.height);
        }
    }
}

static void testKeyAreaSpansTheBandWithinOneKeyAtEveryTestedWidth() {
    for (const int base : kBaseWidths) {
        for (const double scale : kWidthMultipliers) {
            const int w = static_cast<int>(base * scale);
            const StripLayout L = layoutStrip(w, 30);
            CHECK(L.band.width == w);  // the strip always spans the width it was handed
            if (L.keys.empty()) continue;
            // The keys cover all but w % 75 — the price of uniform integer key widths, and
            // the reason the residue is a margin rather than a per-key rounding wobble.
            const int margins = L.band.width - L.keys.width;
            CHECK(margins == w % kStripWhiteKeyCount);
            CHECK(margins >= 0 && margins < kStripWhiteKeyCount);
            const int leftMargin = L.keys.x - L.band.x;
            CHECK(leftMargin == margins / 2);  // split evenly, odd pixel to the right
            CHECK(L.keys.x >= L.band.x && L.keys.right() <= L.band.right());
        }
    }
}

// --- key classification + white ordinals --------------------------------------

static void testIsNaturalKeyAcrossAnOctaveAndTheExtremes() {
    // C4..B4 (MIDI 60..71).
    const bool expected[12] = {true, false, true, false, true, true,
                               false, true, false, true, false, true};
    for (int i = 0; i < 12; ++i) CHECK(isNaturalKey(60 + i) == expected[i]);

    CHECK(isNaturalKey(0) == true);     // C-1
    CHECK(isNaturalKey(1) == false);    // C#-1
    CHECK(isNaturalKey(127) == true);   // G9
    CHECK(isNaturalKey(126) == false);  // F#9
    // Out-of-range clamps rather than indexing off the table.
    CHECK(isNaturalKey(-100) == true);
    CHECK(isNaturalKey(200) == true);
}

static void testWhiteIndexCountsNaturalsBelowTheNote() {
    CHECK(whiteIndexOf(0) == 0);    // C-1 is the first white key
    CHECK(whiteIndexOf(1) == 1);    // C#-1 straddles the C/D boundary
    CHECK(whiteIndexOf(2) == 1);    // D-1 is the second white key
    CHECK(whiteIndexOf(4) == 2);    // E-1
    CHECK(whiteIndexOf(5) == 3);    // F-1 (no black between E and F)
    CHECK(whiteIndexOf(11) == 6);   // B-1
    CHECK(whiteIndexOf(12) == 7);   // C0 opens the next octave
    CHECK(whiteIndexOf(60) == 35);  // C4
    CHECK(whiteIndexOf(127) == kStripWhiteKeyCount - 1);  // G9 is the last white key
}

// --- keyRect ------------------------------------------------------------------

static void testWhiteKeysTileTheKeyAreaGapFree() {
    const StripLayout L = wideStrip();
    int expectedLeft = L.keys.x;
    for (int n = 0; n < kStripKeyCount; ++n) {
        if (!isNaturalKey(n)) continue;
        const Rect k = keyRect(L, n);
        CHECK(k.x == expectedLeft);
        expectedLeft = k.right();
    }
    CHECK(expectedLeft == L.keys.right());  // the last white ends exactly on the key area
}

static void testBlackKeysStraddleTheirWhiteBoundary() {
    const StripLayout L = wideStrip();
    for (int n = 1; n < kStripKeyCount - 1; ++n) {
        if (isNaturalKey(n)) continue;
        const Rect black = keyRect(L, n);
        const Rect below = keyRect(L, n - 1);  // the natural under the accidental
        const Rect above = keyRect(L, n + 1);
        CHECK(black.x > below.x && black.right() < above.right());
        CHECK(black.x < below.right());   // overlaps the white on its left
        CHECK(black.right() > above.x);   // and the white on its right
    }
}

static void testRootMarkerIsTheRootKey() {
    const StripLayout L = wideStrip();
    CHECK(rootMarkerRect(L, 64) == keyRect(L, 64));
    CHECK(rootMarkerRect(L, 61) == keyRect(L, 61));
    // Out-of-range roots clamp instead of producing a stray rect.
    CHECK(rootMarkerRect(L, -5) == keyRect(L, 0));
    CHECK(rootMarkerRect(L, 999) == keyRect(L, 127));
}

// --- keyAtPoint ---------------------------------------------------------------

static void testEveryKeyIsReachableAtItsOwnCentre() {
    const StripLayout L = wideStrip();
    for (int n = 0; n < kStripKeyCount; ++n) {
        const Rect k = keyRect(L, n);
        const int cx = k.x + k.width / 2;
        // A white key only answers below the black zone, where the accidentals end.
        const int cy = isNaturalKey(n) ? L.keys.bottom() - 1 : k.y + k.height / 2;
        CHECK(keyAtPoint(L, cx, cy) == n);
    }
}

static void testBlackKeysWinInTheirZoneAndWhitesWinBelowIt() {
    const StripLayout L = wideStrip();
    const Rect cSharp = keyRect(L, 61);  // C#4
    const int cx = cSharp.x + cSharp.width / 2;
    CHECK(keyAtPoint(L, cx, cSharp.y) == 61);            // in the black zone
    const int below = keyAtPoint(L, cx, L.keys.bottom() - 1);
    CHECK(below != 61);                                  // below it, a white answers
    CHECK(below == 60 || below == 62);                   // C4 or D4, whichever it overlaps
    // E-F and B-C have no accidental between them: the top row there is still white.
    const Rect e4 = keyRect(L, 64);
    CHECK(keyAtPoint(L, e4.right() - 1, e4.y) == 64);
}

static void testKeyAtPointMissesOffBandAndInTheEndMargins() {
    const StripLayout L = layoutStrip(824, 30);
    CHECK(keyAtPoint(L, -5, 5) == -1);
    CHECK(keyAtPoint(L, L.band.right() + 5, 5) == -1);
    CHECK(keyAtPoint(L, 100, L.band.bottom() + 5) == -1);
    CHECK(keyAtPoint(L, 100, -1) == -1);
    if (L.keys.x > L.band.x) CHECK(keyAtPoint(L, L.band.x, 5) == -1);  // left cheek margin
    if (L.keys.right() < L.band.right())
        CHECK(keyAtPoint(L, L.band.right() - 1, 5) == -1);             // right cheek margin
}

// --- the root affordance ------------------------------------------------------

static void testHitTestingAKeyMarksThatSameKey() {
    // The pure half of "click a key, the displayed root moves there": whatever keyAtPoint
    // resolves, the root marker lands exactly on that key — no off-by-one between the key
    // the pointer hit and the key drawn lit.
    const StripLayout L = wideStrip();
    for (int n = 0; n < kStripKeyCount; ++n) {
        const Rect k = keyRect(L, n);
        const int cx = k.x + k.width / 2;
        const int cy = isNaturalKey(n) ? L.keys.bottom() - 1 : k.y + k.height / 2;
        const int hit = keyAtPoint(L, cx, cy);
        CHECK(hit == n);
        CHECK(rootMarkerRect(L, hit) == k);
    }
}

// --- resolveDragNote ----------------------------------------------------------

static void testDragTracksThePointerAndClampsWhenItWanders() {
    const StripLayout L = wideStrip();
    const Rect g4 = keyRect(L, 67);
    const int cx = g4.x + g4.width / 2;
    CHECK(resolveDragNote(L, cx, L.keys.bottom() - 1) == 67);

    // Wandering off the strip keeps tracking at the clamped edge rather than dropping out.
    CHECK(resolveDragNote(L, -500, L.keys.bottom() - 1) == 0);
    CHECK(resolveDragNote(L, L.band.right() + 500, L.keys.bottom() - 1) == 127);
    // Above the strip clamps into the black zone; G4's centre is clear of both flanking
    // accidentals, so it still answers G4 rather than F#4 or G#4.
    CHECK(resolveDragNote(L, cx, -400) == 67);
    CHECK(resolveDragNote(L, cx, 4000) == 67);
}

// --- noteName -----------------------------------------------------------------

static void testNoteNamesFollowTheC4Convention() {
    CHECK(noteName(60) == "C4");   // middle C, the convention REAPER uses
    CHECK(noteName(61) == "C#4");
    CHECK(noteName(59) == "B3");   // the octave rolls at B->C, not at A->B
    CHECK(noteName(72) == "C5");
    CHECK(noteName(0) == "C-1");   // the low extreme
    CHECK(noteName(11) == "B-1");
    CHECK(noteName(12) == "C0");
    CHECK(noteName(127) == "G9");  // the high extreme
    CHECK(noteName(126) == "F#9");
    // Out-of-range clamps to the extremes rather than naming an unplayable note.
    CHECK(noteName(-1) == "C-1");
    CHECK(noteName(500) == "G9");
}

// --- the gutter at the sawtooth's maximum residue -------------------------------

// The rule-based sweep above pins `margins == w % 75` and `leftMargin == margins/2` at every
// width, but pins no concrete number. The strip is a sawtooth with period kStripWhiteKeyCount
// (75) px of window width; 840 is an arbitrary but independently useful sample because it
// lands on residue 74 — the cycle's maximum: one pixel of resize (840->841) collapses both
// gutters to zero and grows every white key from 10px to 11px. Kept as a synthetic worst-case
// sample; NOT tied to any shipped window size (see testGutterAtTheShippedDefaultWindowSize
// below for that).
static void testGutterAtTheSawtoothMaximumResidueWidth() {
    constexpr int kSawtoothMaxResidueStripW = 824;  // an arbitrary width landing on residue 74
    const StripLayout L = layoutStrip(kSawtoothMaxResidueStripW, 30);
    CHECK(L.whiteWidth == 10);
    const int margins = L.band.width - L.keys.width;
    CHECK(margins == 74);
    const int leftMargin = L.keys.x - L.band.x;
    CHECK(leftMargin == 37);
}

// --- the gutter at the shipped default window size -----------------------------

// Daniel is making a visual call on the specific gutter at the shipped default; neither kPad
// nor the floor is covered by another test firing if either ever changes. Derived from
// kEditorMinWidth/kEditorMinHeight (editor_session.cpp's ViewRect default IS the floor) rather
// than a hardcoded window size, so a floor change fails HERE instead of silently moving the
// shipped gutter out from under it. The numbers below are today's floor (1198x680); re-derive
// them by hand if the floor ever moves.
static void testGutterAtTheShippedDefaultWindowSize() {
    // Derive rootStrip's width the same way the shell does, through the real allocator +
    // chrome layout, rather than re-deriving the inset formula.
    const SampleBands bands = computeSampleBands(kEditorMinWidth, kEditorMinHeight, 0);
    const ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24);
    const int stripW = chrome.rootStrip.width;
    CHECK(stripW == kEditorMinWidth - 2 * kPad);
    CHECK(stripW == 1182);

    const StripLayout L = layoutStrip(stripW, 30);
    CHECK(L.whiteWidth == 15);
    const int margins = L.band.width - L.keys.width;
    CHECK(margins == 57);
    const int leftMargin = L.keys.x - L.band.x;
    CHECK(leftMargin == 28);
}

int main() {
    testLayoutFillsTheBandAndCentresTheKeys();
    testDegenerateSizesYieldNoKeys();
    testSameClassKeysAreEqualWidthAcrossClientWidths();
    testKeyAreaSpansTheBandWithinOneKeyAtEveryTestedWidth();
    testIsNaturalKeyAcrossAnOctaveAndTheExtremes();
    testWhiteIndexCountsNaturalsBelowTheNote();
    testWhiteKeysTileTheKeyAreaGapFree();
    testBlackKeysStraddleTheirWhiteBoundary();
    testRootMarkerIsTheRootKey();
    testEveryKeyIsReachableAtItsOwnCentre();
    testBlackKeysWinInTheirZoneAndWhitesWinBelowIt();
    testKeyAtPointMissesOffBandAndInTheEndMargins();
    testHitTestingAKeyMarksThatSameKey();
    testDragTracksThePointerAndClampsWhenItWanders();
    testNoteNamesFollowTheC4Convention();
    testGutterAtTheSawtoothMaximumResidueWidth();
    testGutterAtTheShippedDefaultWindowSize();

    if (g_fail == 0) {
        std::printf("keyboard_strip: all tests passed\n");
        return 0;
    }
    std::printf("keyboard_strip: %d failure(s)\n", g_fail);
    return 1;
}
