// Standalone tests for reasampler::component_geometry — no REAPER, no LICE, no framework.
// Same fast assert loop as the sibling pure tests (prune_button / mode_switch).
//
// Covers (brief §L1 point 2 + §test cases): button box inset + graceful suppression; slider
// track/filled/handle geometry for representative values incl. endpoints, value->px inverse,
// too-small/degenerate suppression; list-row rect for representative indices, partial last
// row, hover hit-test returns the right row and "no hit" outside/past the last row; the
// shared half-open box hit-test agrees with layout (no double-claimed pixel); and the waveform
// column's vertical extents — symmetry about the zero line, monotonicity, and the band clamp
// on both the fill and the antialiased stroke, which draw_kit's shell cannot cover.

#include "../src/core/ui/component_geometry.h"

#include <cstdio>

using namespace reasampler;
using namespace reasampler::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)

// --- hitTestBox (the shared primitive) ---------------------------------------

static void testHitTestBoxHalfOpen() {
    KitBox b{10, 20, 30, 40};
    CHECK(hitTestBox(10, 20, b));               // top-left inclusive
    CHECK(hitTestBox(39, 59, b));               // bottom-right inclusive (x+w-1, y+h-1)
    CHECK(!hitTestBox(40, 20, b));              // right edge excluded
    CHECK(!hitTestBox(10, 60, b));              // bottom edge excluded
    CHECK(!hitTestBox(9, 20, b));               // just left
    CHECK(!hitTestBox(10, 19, b));              // just above
    KitBox empty{};
    CHECK(!hitTestBox(0, 0, empty));            // empty claims nothing
}

// --- Button box --------------------------------------------------------------

static void testButtonBoxInset() {
    KitBox cell{0, 0, 100, 40};
    const KitButtonBox b = computeButtonBox(cell, 4);
    CHECK(!b.box.empty());
    CHECK((b.box == KitBox{4, 4, 92, 32}));
}

static void testButtonBoxZeroPadding() {
    KitBox cell{5, 6, 20, 10};
    const KitButtonBox b = computeButtonBox(cell, 0);
    CHECK((b.box == cell));                     // no inset -> the whole cell
}

static void testButtonBoxNegativePaddingTreatedAsZero() {
    KitBox cell{5, 6, 20, 10};
    CHECK((computeButtonBox(cell, -8).box == cell));
}

static void testButtonBoxSuppressedWhenPaddingCollapses() {
    KitBox cell{0, 0, 10, 10};
    CHECK(computeButtonBox(cell, 5).box.empty());   // 10 - 2*5 = 0 width -> suppressed
    CHECK(computeButtonBox(cell, 6).box.empty());   // negative -> suppressed
    CHECK(computeButtonBox(KitBox{}, 2).box.empty());  // degenerate cell -> suppressed
}

// --- Slider ------------------------------------------------------------------

// control 200x20, handle 12, track thickness 4. half = 6. track.x = 6, track.width =
// 200-12 = 188, track.y = (20-4)/2 = 8. At value 0 the handle centre is track.x=6 ->
// handle.x = 0; filled.width = 0. At value 1 the centre is 6+188=194 -> handle.x=188.
static void testSliderEndpoints() {
    KitBox ctrl{0, 0, 200, 20};
    const SliderGeometry lo = computeSlider(ctrl, 0.0, 12, 4);
    CHECK(!lo.track.empty());
    CHECK((lo.track == KitBox{6, 8, 188, 4}));
    CHECK(lo.handle.x == 0);                    // flush left
    CHECK(lo.filled.width == 0);                // nothing filled at 0
    const SliderGeometry hi = computeSlider(ctrl, 1.0, 12, 4);
    CHECK(hi.handle.x == 188);                  // flush right (200-12)
    CHECK(hi.filled.width == 188);              // fully filled at 1
}

static void testSliderMidpoint() {
    KitBox ctrl{0, 0, 200, 20};
    const SliderGeometry m = computeSlider(ctrl, 0.5, 12, 4);
    // centre = 6 + round(0.5*188) = 6 + 94 = 100; handle.x = 100 - 6 = 94.
    CHECK(m.handle.x == 94);
    CHECK(m.filled.width == 94);
}

static void testSliderClampsValue() {
    KitBox ctrl{0, 0, 200, 20};
    CHECK((computeSlider(ctrl, -1.0, 12, 4).handle.x == computeSlider(ctrl, 0.0, 12, 4).handle.x));
    CHECK((computeSlider(ctrl, 5.0, 12, 4).handle.x == computeSlider(ctrl, 1.0, 12, 4).handle.x));
}

static void testSliderSuppressedWhenTooSmallOrDegenerate() {
    CHECK(computeSlider(KitBox{0, 0, 8, 20}, 0.5, 12, 4).track.empty());   // width < handle
    CHECK(computeSlider(KitBox{0, 0, 200, 8}, 0.5, 12, 4).track.empty());  // height < handle
    CHECK(computeSlider(KitBox{}, 0.5, 12, 4).track.empty());              // degenerate
    CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 0, 4).track.empty());  // no handle
    CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 12, 0).track.empty()); // no track
}

// value->px is the inverse of the handle placement (a track jump).
static void testSliderValueAtInverse() {
    KitBox ctrl{0, 0, 200, 20};
    CHECK(sliderValueAt(6, ctrl, 12) == 0.0);              // at/left of track start
    CHECK(sliderValueAt(0, ctrl, 12) == 0.0);              // left of the control -> 0
    CHECK(sliderValueAt(194, ctrl, 12) == 1.0);            // at track end -> 1
    CHECK(sliderValueAt(300, ctrl, 12) == 1.0);            // past the end -> clamped
    const double mid = sliderValueAt(100, ctrl, 12);       // (100-6)/188
    CHECK(mid > 0.49 && mid < 0.51);
    CHECK(sliderValueAt(50, KitBox{0, 0, 8, 20}, 12) == 0.0);  // too small -> 0
}

// --- List row ----------------------------------------------------------------

static void testListRowStacking() {
    KitBox list{0, 100, 220, 90};  // 3 full rows of 30 fit
    CHECK((computeListRow(list, 0, 30).box == KitBox{0, 100, 220, 30}));
    CHECK((computeListRow(list, 1, 30).box == KitBox{0, 130, 220, 30}));
    CHECK((computeListRow(list, 2, 30).box == KitBox{0, 160, 220, 30}));
    CHECK(computeListRow(list, 0, 30).index == 0);
    CHECK(computeListRow(list, 2, 30).index == 2);
}

// A row that starts inside the list but overhangs the bottom keeps full height (caller
// clips the draw); a row starting AT/BELOW the bottom is suppressed.
static void testListRowPartialAndClipped() {
    KitBox list{0, 0, 100, 50};  // rows of 30: row 0 [0,30), row 1 [30,60) overhangs
    const ListRowBox partial = computeListRow(list, 1, 30);
    CHECK(!partial.box.empty());
    CHECK(partial.box.y == 30);
    CHECK(partial.box.height == 30);            // full height; caller clips
    CHECK(computeListRow(list, 2, 30).box.empty());  // starts at y=60 >= bottom -> none
}

static void testListRowDegenerate() {
    CHECK(computeListRow(KitBox{}, 0, 30).box.empty());
    CHECK(computeListRow(KitBox{0, 0, 100, 90}, -1, 30).box.empty());
    CHECK(computeListRow(KitBox{0, 0, 100, 90}, 0, 0).box.empty());
}

static void testListRowHitTest() {
    KitBox list{0, 100, 220, 90};  // 3 rows of 30, rowCount = 3
    CHECK(hitTestListRow(10, 100, list, 30, 3) == 0);    // top of row 0
    CHECK(hitTestListRow(10, 129, list, 30, 3) == 0);    // bottom of row 0 (inclusive)
    CHECK(hitTestListRow(10, 130, list, 30, 3) == 1);    // top of row 1
    CHECK(hitTestListRow(10, 189, list, 30, 3) == 2);    // last pixel of row 2
    // Misses.
    CHECK(hitTestListRow(10, 99, list, 30, 3) == -1);    // above the list
    CHECK(hitTestListRow(10, 190, list, 30, 3) == -1);   // below the list band
    CHECK(hitTestListRow(-1, 100, list, 30, 3) == -1);   // left of the list
    CHECK(hitTestListRow(220, 100, list, 30, 3) == -1);  // right edge excluded
}

// rowCount bounds the hit: a taller list with fewer rows than fit reports the empty tail
// as a miss (no phantom row past the data).
static void testListRowHitTestBoundedByCount() {
    KitBox list{0, 0, 100, 200};  // room for 6 rows of 30, but only 2 exist
    CHECK(hitTestListRow(10, 10, list, 30, 2) == 0);
    CHECK(hitTestListRow(10, 40, list, 30, 2) == 1);
    CHECK(hitTestListRow(10, 70, list, 30, 2) == -1);    // row 2 is empty tail -> miss
    CHECK(hitTestListRow(10, 10, list, 30, 0) == -1);    // zero rows -> always miss
}

// Draw/hit-test agreement: every pixel inside a computed row hit-tests to that row.
static void testListRowLayoutHitAgreement() {
    KitBox list{3, 7, 97, 120};
    const int rh = 24, rowCount = 4;
    for (int idx = 0; idx < rowCount; ++idx) {
        const ListRowBox r = computeListRow(list, idx, rh);
        if (r.box.empty()) continue;
        for (int py = r.box.y; py < r.box.y + r.box.height &&
                               py < list.y + list.height; ++py)
            CHECK(hitTestListRow(list.x + 1, py, list, rh, rowCount) == idx);
    }
}

// --- waveformColumnCount -----------------------------------------------------

static void testWaveformColumnCount() {
    // Normal box: width minus the two 2px side insets.
    CHECK(waveformColumnCount(KitBox{0, 0, 100, 40}) == 96);
    CHECK(waveformColumnCount(KitBox{10, 5, 50, 20}) == 46);
    // Minimum: a 5-wide box yields exactly 1 drawable column.
    CHECK(waveformColumnCount(KitBox{0, 0, 5, 10}) == 1);
    // Too narrow to have any drawable columns: 4 or fewer columns returns 0.
    CHECK(waveformColumnCount(KitBox{0, 0, 4, 10}) == 0);
    CHECK(waveformColumnCount(KitBox{0, 0, 1, 10}) == 0);
    // Degenerate (empty) box returns 0 — graceful suppression.
    CHECK(waveformColumnCount(KitBox{}) == 0);
    CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0);
}

// --- waveform column span ----------------------------------------------------

// The regression this exists to catch: rounding applied to the resulting y instead of to the
// scaled amplitude draws a symmetric column one pixel taller above the zero line than below.
static void testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine() {
    // Odd height so midY sits equidistant from lo/hi — the fractional half-span below comes
    // from the amplitude product, not the height's parity.
    const WaveformBand band = waveformBand(0, 41);
    // Sweep amplitudes whose scaled value is fractional, which is where the two edges can
    // round in opposite directions.
    for (int i = 1; i <= 20; ++i) {
        const double a = i / 20.0;
        const WaveformColumnSpan s = waveformColumnSpan(band, a, -a);
        CHECK(band.midY - s.top == s.bottom - band.midY);
        // The stroke's edges are symmetric to sub-ULP, not bit-exactly: midY +/- x rounds the
        // two sides independently. Any REAL asymmetry here would be a whole pixel.
        const double above = band.midY - s.topF;
        const double below = s.bottomF - band.midY;
        CHECK(above - below < 1e-9 && below - above < 1e-9);
    }
}

// A column with no signal collapses onto the zero line rather than spanning a pixel of it.
static void testSilentColumnCollapsesOntoTheZeroLine() {
    const WaveformBand band = waveformBand(10, 40);
    const WaveformColumnSpan s = waveformColumnSpan(band, 0.0, 0.0);
    CHECK(s.top == band.midY && s.bottom == band.midY);
    CHECK(s.topF == band.midY && s.bottomF == band.midY);
}

// Amplitude grows the span monotonically, and a bigger amplitude never draws shorter.
static void testTallerAmplitudeNeverDrawsAShorterColumn() {
    const WaveformBand band = waveformBand(0, 40);
    int prevHeight = -1;
    for (int i = 0; i <= 20; ++i) {
        const WaveformColumnSpan s = waveformColumnSpan(band, i / 20.0, -(i / 20.0));
        const int h = s.bottom - s.top;
        CHECK(h >= prevHeight);
        prevHeight = h;
    }
}

// The clamp is the band's boundary for BOTH the fill and the antialiased stroke — a stroke
// vertex outside the band would draw into the neighbouring channel's lane.
static void testBothEdgesAndTheStrokeClampToTheBand() {
    const WaveformBand band = waveformBand(100, 40);
    const int lo = 100;
    const int hi = 139;
    // Past full scale in both directions (the display curve's own range is [-1,1], so this is
    // the defensive case, not a reachable one).
    const WaveformColumnSpan s = waveformColumnSpan(band, 8.0, -8.0);
    CHECK(s.top == lo && s.bottom == hi);
    CHECK(s.topF == lo && s.bottomF == hi);
    // Full scale sits INSIDE the band by the half-span's 2px inset — the clamp is a guard,
    // not the thing that produces the normal drawn height.
    const WaveformColumnSpan full = waveformColumnSpan(band, 1.0, -1.0);
    CHECK(full.top > lo && full.bottom < hi);
}

static void testBandMetricsMirrorTheDrawnInset() {
    const WaveformBand b = waveformBand(50, 40);
    CHECK(b.top == 50 && b.height == 40);
    CHECK(b.midY == 70);
    CHECK(b.halfSpan == 18.0);  // half the band, less the 2px breathing room
}

// A band too short for the 2px breathing room (height <= 4, so height/2 - 2 <= 0) must clamp
// halfSpan to 0 rather than go negative and mirror every positive column below the zero line.
static void testDegenerateBandClampsHalfSpanToZero() {
    CHECK(waveformBand(0, 4).halfSpan == 0.0);
    CHECK(waveformBand(0, 3).halfSpan == 0.0);
    CHECK(waveformBand(0, 0).halfSpan == 0.0);
    // A zero half-span collapses every column onto the zero line regardless of amplitude sign.
    // height 3 (not 4): pre-clamp this rawHalfSpan is -1, so this is the case that pins the
    // clamp itself, not just a halfSpan-already-zero band.
    const WaveformBand b = waveformBand(10, 3);
    const WaveformColumnSpan s = waveformColumnSpan(b, 1.0, -1.0);
    CHECK(s.top == b.midY && s.bottom == b.midY);
    // Domain note, not a bug: waveformBand(t, 0) still has lo=t, hi=t-1 (an inverted span) even
    // though halfSpan clamps to 0 — unreachable via the band allocator (draw_kit.cpp never
    // divides down to a 0-height band) and harmless if it were (LICE clips a reversed 1px line).
}

int main() {
    testHitTestBoxHalfOpen();
    testButtonBoxInset();
    testButtonBoxZeroPadding();
    testButtonBoxNegativePaddingTreatedAsZero();
    testButtonBoxSuppressedWhenPaddingCollapses();
    testSliderEndpoints();
    testSliderMidpoint();
    testSliderClampsValue();
    testSliderSuppressedWhenTooSmallOrDegenerate();
    testSliderValueAtInverse();
    testListRowStacking();
    testListRowPartialAndClipped();
    testListRowDegenerate();
    testListRowHitTest();
    testListRowHitTestBoundedByCount();
    testListRowLayoutHitAgreement();
    testWaveformColumnCount();
    testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine();
    testSilentColumnCollapsesOntoTheZeroLine();
    testTallerAmplitudeNeverDrawsAShorterColumn();
    testBothEdgesAndTheStrokeClampToTheBand();
    testBandMetricsMirrorTheDrawnInset();
    testDegenerateBandClampsHalfSpanToZero();

    if (g_fail == 0) std::printf("component_geometry: all tests passed\n");
    else std::printf("component_geometry: %d CHECK(s) FAILED\n", g_fail);
    return g_fail == 0 ? 0 : 1;
}
