// Layout-BUDGET tests for reasampler::instrument::ui::deck_groups, split from
// test_deck_groups.cpp on the seam CMakeLists.txt already named: these fixtures need
// sample_bands (the window-floor constants, computeSampleBands) and master_meter
// (kMeterColumnW, MASTER's reserve), which test_deck_groups.cpp's WHICH-descriptors fixtures do
// not. Pins the editor floor's derivation from the deck's width budget, the row/gutter
// justification arithmetic at and above the floor, the pinned Gate group widths, and MASTER's
// interior to the pixel. test_deck_groups.cpp pins WHICH descriptors the deck carries; this
// file pins what the width budget MEASURES them at.

#include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/master_meter.h"  // kMeterColumnW: MASTER's reserve IS this
#include "../src/core/instrument/ui/sample_bands.h"

#include <cstdio>
#include <vector>

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)

// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less
// the band allocator's kPad inset on each side.
static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad;

static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
    for (std::size_t i = 0; i < g.size(); ++i) {
        if (g[i].id == id) return static_cast<int>(i);
    }
    return -1;
}

static int cell(DeckParam p) { return static_cast<int>(p); }

// The guard the raised floor exists to provide: at the smallest window the host can produce,
// the deck band still lands inside the client area AND the waveform still gets its two-lane
// floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing
// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them.
static void testDeckFitsInsideTheEnforcedMinimumWindow() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        const int h = deckHeight(g);
        const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
        CHECK(deckRowCount(g) == 2);  // either face
        CHECK(b.decks.height == h);
        // The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the
        // waveform 358, against 328/246 before. Pinned now that both are reached by
        // construction rather than by a pack outcome.
        CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap);
        CHECK(b.waveform.height == 358);
        // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
        // deck down until the waveform hits its floor, so any deck too tall to fit stops
        // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the
        // degrade can still leave the deck ending at the window edge.
        CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
        CHECK(b.waveform.height >= kWaveformMinHeight);
    }
}

// The floor is a DERIVED number, and this is the one place the derivation is written down —
// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is
// the only one that includes both.
static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
    CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth);
    // The budget: what is left between the derived floor and the hard ceiling, and it is spent
    // once. A cell costs 60 of it.
    CHECK(kEditorCeilingWidth - kEditorMinWidth == 82);
    // 82 still buys one more deck cell (60), which is the only purchase the ledger promises —
    // the widen below spent 8 px of slack, not the layout's purchasing power.
    CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW);
    // The reflow's 112 px goes entirely to the waveform, so the height does not move.
    CHECK(kEditorMinHeight == 680);
    // 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on
    // one pixel, which is the only reason the floor moved off its originally specified value.
    CHECK(kEditorMinWidth == 1198);
    CHECK(kEditorMinWidth <= kEditorCeilingWidth);
    CHECK(kEditorMinHeight <= 720);
    // And the row block really is what the two rows justify inside — derived from the floor
    // and the spanning reserve, not restated.
    CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
}

// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move
// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
// them at 312 in Trigger too — asserted here rather than assumed.
static void testBothRowsAndTheSpanningDeckFitTheBudget() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        int width[3] = {0, 0, 0};
        int count[3] = {0, 0, 0};
        for (const DeckGroupDesc& d : g) {
            const int r = static_cast<int>(deckRowFor(static_cast<DeckGroupId>(d.id)));
            width[r] += deckGroupWidth(d);
            ++count[r];
        }
        const int sound = static_cast<int>(DeckRow::Sound);
        const int contour = static_cast<int>(DeckRow::Contour);
        const int spanning = static_cast<int>(DeckRow::Spanning);

        CHECK(count[sound] == 4);
        CHECK(width[sound] == 980);   // 192 + 432 + 192 + 164
        CHECK(count[contour] == 3);
        CHECK(width[contour] == 876);  // 252 + 312 + 312
        CHECK(count[spanning] == 1);
        CHECK(width[spanning] == kDeckSpanningW);  // 142 exactly — the reserve is now spent

        for (int r : {sound, contour}) {
            CHECK(width[r] <= kDeckRowBlockW);
            // Slack enough that no gutter in the row falls under the minimum.
            CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap);
        }
    }
}

// The gutters the justification law produces at the floor, and the alignment they buy.
// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than
// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's
// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three
// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap
// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule.
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);

    const auto box = [&](int id) {
        return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box;
    };
    // Row 1: flush left, flush right on the block, and three EQUAL gutters — 48 divides by 3
    // with no residue, so no gutter carries a leftover pixel.
    CHECK(box(kGroupPitch).x == kPad);
    CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16);
    CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16);
    CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16);
    CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);

    // Row 2: flush left, flush right, two gutters exactly equal.
    CHECK(box(kGroupPitchEnv).x == kPad);
    CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76);
    CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
    CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);

    // The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen
    // bought. Pinned as an identity too, so a group-width change cannot pass by moving both.
    CHECK(box(kGroupFilterEnv).right() - kPad == 640);
    CHECK(box(kGroupFilter).right() - kPad == 640);
    CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right());

    // MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
    CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
    CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth);
}

// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
//
// Checked per ROW (tracking the last-seen box in each of the two categorical rows while
// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can
// sit apart in deck order with a different-row group between them, and a deck-order-only
// check would silently skip that gutter.
static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        int lastDrift = 1 << 20;  // sentinel above any real drift
        for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) {
            const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
            const DeckGroupLayout* prevInRow[2] = {nullptr, nullptr};
            for (const DeckGroupLayout& gl : dl.groups) {
                const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
                if (row == DeckRow::Spanning) continue;
                const int r = row == DeckRow::Contour ? 1 : 0;
                if (prevInRow[r]) {
                    CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap);
                }
                prevInRow[r] = &gl;
            }
            const auto right = [&](int id) {
                return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box.right();
            };
            // Flush right on the block at every width, both rows.
            CHECK(right(kGroupVoice) == right(kGroupAmpEnv));
            // Monotone in width rather than oscillating: row 2's two gutters absorb slack
            // faster than row 1's three, so the gap only ever opens.
            const int drift = right(kGroupFilter) - right(kGroupFilterEnv);
            CHECK(drift <= lastDrift);
            lastDrift = drift;
        }
        // It really does open up: the tie-line is exact AT the floor and separates above it,
        // which is the accepted outcome rather than a near-miss to be pinned back.
        CHECK(lastDrift < -50);
    }
}

// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own
// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect
// across both, never a readout per row.
static void testTheMasterDeckInteriorLandsOnBothRowBaselines() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
    const DeckGroupLayout& m =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];

    CHECK(m.box.width == kDeckSpanningW);
    CHECK(m.box.height == 216);
    // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter
    // module's own kMeterColumnW rather than a copy of it. That link is the whole point: the
    // column is banked to GROW (§1.2), and a reserve that did not track it would leave the
    // interior underfilling or overrunning with every test still green.
    CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX ==
          kDeckSpanningW);
    CHECK(m.column.id == cell(DeckParam::kMasterMeter));
    CHECK(m.column.box.width == kMeterColumnW);

    // One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed
    // position and draws nothing.
    CHECK(m.cells.size() == 1);
    CHECK(m.cells[0].id == cell(DeckParam::kMasterGain));
    CHECK(m.cells[0].cell.y - m.box.y == 26);
    const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap;
    CHECK(reserveTop - m.box.y == 138);

    // The two baselines are row 1's and row 2's own.
    const DeckGroupLayout& filter =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
    const DeckGroupLayout& amp =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
    CHECK(m.cells[0].cell.y == filter.cells[0].cell.y);
    CHECK(reserveTop == amp.cells[0].cell.y);

    // The meter: one rect spanning both baselines, 62 x 186.
    CHECK(m.column.id == cell(DeckParam::kMasterMeter));
    CHECK(m.column.box.width == kMeterColumnW);
    CHECK(m.column.box.height == 186);
    CHECK(m.column.box.y == m.cells[0].cell.y);
    CHECK(m.column.box.bottom() - m.box.y == 212);
}

// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left
// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law
// applied vertically — which would stretch the one gain knob over the whole 186 px.
static void testTheMasterColumnDoesNotDivideItsRunVertically() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
    const DeckGroupLayout& m =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
    CHECK(m.cells[0].cell.height == kDeckCellH);
    CHECK(m.cells[0].cell.width == kDeckCellW);
    // Under the run-division law the lone present cell would take the whole two-slot run;
    // here it takes exactly one slot and leaves the rest empty.
    CHECK(m.cells[0].cell.height < m.column.box.height);
    CHECK(m.cells[0].cell.bottom() < m.column.box.bottom());
    CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize);
    // And dropping the reserve does not move the gain knob or the meter — the slot below it is
    // reserved height, so nothing above it depends on whether it is there.
    std::vector<DeckGroupDesc> noReserve = g;
    for (DeckGroupDesc& d : noReserve) {
        if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)};
    }
    const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth);
    const DeckGroupLayout& m2 =
        dl2.groups[static_cast<std::size_t>(indexOfGroup(noReserve, kGroupMaster))];
    CHECK(m2.cells[0].cell == m.cells[0].cell);
    CHECK(m2.column.box == m.column.box);
}

// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column
// derived from either edge lands in the same place — that balance is what let a left-derived
// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or
// a limiter-toggle change would) and the column must still land flush against the group's own
// right padding, derived from innerRight rather than measured past the cell slots.
static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
    probe.captionWidth += 40;  // unbalances it: the caption row now measures past the knob row
    const std::vector<DeckGroupDesc> one = {probe};
    const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth);
    const DeckGroupLayout& m = dl.groups[0];
    CHECK(m.box.width > kDeckSpanningW);  // the widen is real, not absorbed elsewhere
    CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX);
}

// The three mode toggles ride each env group's caption slack, on the CONTOUR row: raising
// their segment width past the caption headroom would widen that row and eat its gutters,
// not add a row — row count is a property of the group inventory, not of width.
static void testTheModeTogglesCostNoGroupWidth() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
            if (g.captionToggle2.id < 0) continue;
            DeckGroupDesc without = g;
            without.captionToggle2 = DeckToggleDesc{};
            CHECK(deckGroupWidth(g) == deckGroupWidth(without));
        }
    }
}

// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus
// padding) is what it measures from, and the caption row must stay under that. The ceiling is
// asserted by construction rather than as a comment: at a caption reserve of 80 the group is
// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the
// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is.
static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    const DeckGroupDesc* pitch = nullptr;
    for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d;
    CHECK(pitch != nullptr);
    if (!pitch) return;
    CHECK(pitch->cellIds.size() == 3);
    CHECK(pitch->cellIds[0] == static_cast<int>(DeckParam::kKeyTrack));
    CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
    CHECK(pitch->cellIds[2] == static_cast<int>(DeckParam::kPitch));
    CHECK(deckGroupWidth(*pitch) == 192);
    CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192);  // the knob row IS the measurement

    DeckGroupDesc probe = *pitch;
    probe.captionWidth = 80;
    CHECK(deckGroupWidth(probe) == 192);  // at the ceiling the caption row still fits under it
    probe.captionWidth = 81;
    CHECK(deckGroupWidth(probe) > 192);   // one past it, the caption row takes over
}

// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47,
// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate
// caption ceiling above is: a change to either group's caption width or its enable toggle
// would otherwise invalidate the recorded numbers with nothing failing.
static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    const DeckGroupDesc& penv = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
    const DeckGroupDesc& amp  = g[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
    CHECK(deckGroupWidth(penv) == 252);
    CHECK(deckGroupWidth(amp) == 312);

    DeckGroupDesc penvProbe = penv;
    penvProbe.captionToggle2.segWidth = 47;
    CHECK(deckGroupWidth(penvProbe) == 252);  // at the ceiling, still knob-row-driven
    penvProbe.captionToggle2.segWidth = 48;
    CHECK(deckGroupWidth(penvProbe) > 252);   // one past it, the caption row takes over

    DeckGroupDesc ampProbe = amp;
    ampProbe.captionToggle2.segWidth = 55;
    CHECK(deckGroupWidth(ampProbe) == 312);
    ampProbe.captionToggle2.segWidth = 56;
    CHECK(deckGroupWidth(ampProbe) > 312);
}

// Every group's width, in BOTH play modes, against the measured layout table
// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the
// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the
// contour row's 876 a constant rather than a Gate-only fact.
static void testEveryGroupWidthMatchesTheMeasuredLayout() {
    const struct { int id; int width; } want[] = {
        {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
        {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
        {kGroupVoice, 164}, {kGroupMaster, 142},
    };
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        CHECK(g.size() == sizeof(want) / sizeof(want[0]));
        const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
        for (const auto& w : want) {
            const int i = indexOfGroup(g, w.id);
            CHECK(i >= 0);
            if (i < 0) continue;
            CHECK(deckGroupWidth(g[static_cast<std::size_t>(i)]) == w.width);
            const DeckGroupLayout& lay =
                dl.groups[static_cast<std::size_t>(indexOfGroup(g, w.id))];
            CHECK(lay.box.width == w.width);
        }
        // Gate carries no reserves, so its cells are the deck's base size; Trigger's two
        // reduced faces divide the same reserved run between fewer cells and get wider ones.
        for (const DeckGroupLayout& lay : dl.groups) {
            for (const DeckCellLayout& c : lay.cells) {
                CHECK(c.cell.width >= kDeckCellW);
                if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW);
            }
        }
    }
}

int main() {
    testDeckFitsInsideTheEnforcedMinimumWindow();
    testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
    testBothRowsAndTheSpanningDeckFitTheBudget();
    testGutterArithmeticAndTheFilterTieLineAtTheFloor();
    testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
    testTheMasterDeckInteriorLandsOnBothRowBaselines();
    testTheMasterColumnDoesNotDivideItsRunVertically();
    testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
    testTheModeTogglesCostNoGroupWidth();
    testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
    testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
    testEveryGroupWidthMatchesTheMeasuredLayout();
    if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
