// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop span,
// the four marks (line + shaped cap + label) with the crossfade's wedge and ghost, and the
// envelope overlay. Windows-only. All cap/label geometry is the pure waveform_view module's.
//
// Overlay contract: see waveform_view.h's WaveformSurface.

#include "shell/instrument/reasampler_editor.h"

#ifdef _WIN32

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>

#include "core/audio/peaks.h"                   // Envelope (the binned waveform)
#include "core/instrument/ui/curve_tessellate.h"  // buildEnvelopeTrace (the staged trace)
#include "core/instrument/ui/spline_edit.h"     // splineOverlayBox (the contour's mapping box)
#include "core/instrument/ui/waveform_view.h"   // waveformSurface / laneEnvelope / frameToX
#include "shell/instrument/editor_internal.h"   // kit adapters
#include "shell/instrument/reasampler_processor.h"

namespace reasampler::vst {

using namespace reasampler::ui;              // kit vocabulary
using namespace reasampler::instrument::ui;  // lanes + waveform geometry

namespace {
// Marker roles — semantic, drawn through the kit's palette. The loop family is teal
// (secondary); markers are 2px bars and a translucent span fill, not the 1px trace, so they live
// with 1.92:1 against the waveform. Do not collapse the two back onto one role — they overlap in
// this rect. The trace crossing the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1
// against a 3:1 floor), and no trace value fixes it — see the two-neighbour rule in
// core/ui/CLAUDE.md. If it is ever resolved, the FILL is what changes; do not nudge a color to
// chase it.
//
// START is deliberately NOT accent/primary, which is what the design called for: accent/primary
// IS the waveform's own fill, so a primary START mark would be 1:1 against the material it marks
// — worse than the teal it replaced, not better. overlay/trace is the one role that clears 3:1
// against BOTH the lime and bg/base (core/ui/CLAUDE.md's two-neighbour ceiling sits exactly on
// it), so it is the only ink that can carry a distinct always-in-effect mark here. It reads
// apart from the envelope trace by shape: a straight full-height column under a solid triangle
// cap, never a curve.
constexpr Role kRoleStartMarker = Role::OverlayTrace;
constexpr Role kRoleLoopMarker  = Role::AccentSecondary;

// Mark weights. A Disabled mark (loop off, or Trigger) keeps its position and its cap so the
// information survives the state; the crossfade is a SOFT boundary and rides below the loop
// pair's weight at rest.
constexpr float kMarkAlpha         = 1.0f;
constexpr float kMarkAlphaXfade    = 0.7f;
constexpr float kMarkAlphaDisabled = 0.4f;

// The dashed crossfade line: a 3 px stroke every 6 px down the band.
constexpr int kDashOn = 3;
constexpr int kDashPeriod = 6;

// The ingredient ghost's weight relative to the audible wedge, and the hairline it draws at
// rest. It fills in only while the crossfade handle is hovered or dragged — the relationship is
// revealed when the user asks about it, not permanently.
constexpr float kGhostAlpha = 0.5f;
constexpr int kGhostHairlinePx = 1;

// Envelope-handle half-extents. Grabbed grows and hollows out; kNodeGrabRadius (envelope_edit)
// is the PICK radius and is unrelated — a handle may draw larger than it without widening any
// hit region.
constexpr int kEnvHandleRadius        = 3;
constexpr int kEnvHandleGrabbedRadius = 5;
constexpr int kEnvHandleRingPx        = 2;

// Both envelope traces — staged and drawn — are one grammar and one weight. Two pixels is what
// reads as a trace rather than a hairline over the waveform behind it.
constexpr float kEnvTracePx = 2.0f;

const char* markLabel(WaveMark m) {
    switch (m) {
        case WaveMark::kStart:     return "START";
        case WaveMark::kLoopStart: return "LOOP";
        case WaveMark::kLoopEnd:   return "END";
        case WaveMark::kCrossfade: return "XFADE";
        case WaveMark::kCount:     break;
    }
    return "";
}

// Font::Micro is proportional, so this is a per-character estimate, not a measured font metric —
// an under-estimate doesn't produce a visible collision (the kit's own DT_END_ELLIPSIS clips the
// box first), it produces a silently TRUNCATED label ("LOO…"). Matches kTooltipCharPx
// (panel_state.h), the codebase's other unmeasured Segoe UI estimate.
constexpr int kMicroCharPx = 7;
int markLabelWidth(WaveMark m) {
    int n = 0;
    for (const char* s = markLabel(m); *s; ++s) ++n;
    return n * kMicroCharPx;
}

// One mark's cap glyph, drawn inside the cap rect the hit-test uses. The four shapes ARE the
// marks' identities — a label may be suppressed, a cap never is.
void drawMarkCap(LICE_IBitmap* bmp, WaveMark which, const Rect& cap, int mx, LICE_pixel ink,
                 float alpha) {
    if (cap.empty()) return;
    const int top = cap.y;
    const int bot = cap.bottom();
    // The cap's own half-width, so glyph == grip — true for LOOP/END/XFADE. START is the
    // exception: its triangle tip (mx - 1 + arm + 2, below) reaches past markerHandleRect's own
    // right edge rather than sitting inside it; not yet corrected, since narrowing the tip is a
    // visual change past this fix's scope.
    const int arm = kMarkerHandleHalfWidth;
    switch (which) {
        case WaveMark::kStart:
            // A play flag: it points into the material that will play.
            LICE_FillTriangle(bmp, mx - 1, top, mx - 1, bot, mx - 1 + arm + 2, (top + bot) / 2,
                              ink, alpha, 0);
            break;
        case WaveMark::kLoopStart:
            // '[' — opens right, into the span.
            LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0);
            LICE_FillRect(bmp, mx - 1, top, arm + 1, 2, ink, alpha, 0);
            LICE_FillRect(bmp, mx - 1, bot - 2, arm + 1, 2, ink, alpha, 0);
            break;
        case WaveMark::kLoopEnd:
            // ']' — opens left, into the span. The opposed pair reads as an enclosure.
            LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0);
            LICE_FillRect(bmp, mx - arm, top, arm + 1, 2, ink, alpha, 0);
            LICE_FillRect(bmp, mx - arm, bot - 2, arm + 1, 2, ink, alpha, 0);
            break;
        case WaveMark::kCrossfade:
            // A ramp whose hypotenuse rises toward the seam — the fade's own shape.
            LICE_FillTriangle(bmp, mx - 1, bot - 1, mx - 1 + arm, bot - 1, mx - 1 + arm, top,
                              ink, alpha, 0);
            break;
        case WaveMark::kCount:
            break;
    }
}

// The crossfade region over [f0, f1) as a top-and-bottom edge wedge. NEVER a fill: the audible
// region sits INSIDE the loop span, and a translucent fill there would stack on the loop fill,
// making the already-accepted 2.25:1 trace pair worse. `filled` false draws the resting ghost —
// a hairline dashed outline of the same wedge.
void drawCrossfadeWedge(LICE_IBitmap* bmp, const OverlayArea& overlay, std::int64_t frames,
                        std::int64_t f0, std::int64_t f1, LICE_pixel ink, float alpha,
                        bool filled) {
    const Rect& r = overlay.rect;
    const int x0 = frameToX(overlay, frames, f0);
    const int x1 = frameToX(overlay, frames, f1);
    if (x1 <= x0 || r.empty()) return;
    for (int x = x0; x < x1; ++x) {
        const int h = crossfadeWedgeHeight(x0, x1, x);
        if (h <= 0) continue;
        if (filled) {
            LICE_FillRect(bmp, x, r.y, 1, h, ink, alpha, 0);
            LICE_FillRect(bmp, x, r.bottom() - h, 1, h, ink, alpha, 0);
        } else if ((x - x0) % kDashPeriod < kDashOn) {
            LICE_FillRect(bmp, x, r.y + h - kGhostHairlinePx, 1, kGhostHairlinePx, ink, alpha, 0);
            LICE_FillRect(bmp, x, r.bottom() - h, 1, kGhostHairlinePx, ink, alpha, 0);
        }
    }
}

// Backing for the state caption and the four mark labels: text/dim and text/primary both read
// under floor against a full-scale envelope peak (accent/primary) with no backing at all — see
// kWaveformLabelScrimAlpha's own note. Sized to the box the caller already resolved, never the
// whole band, so this only darkens the text's own row.
void scrimLabelBox(LICE_IBitmap* bmp, const Rect& box) {
    if (box.empty()) return;
    LICE_FillRect(bmp, box.x, box.y, box.width, box.height, toLice(roleColor(Role::BgBase)),
                  static_cast<float>(kWaveformLabelScrimAlpha), 0);
}
}  // namespace

void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
                                     const SetupMarkers& m) {
    fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest);
    if (band.empty()) return;

    const std::vector<AudioSample>& mono = monoPcmFor(selectedId_);
    const std::int64_t frames = static_cast<std::int64_t>(mono.size());
    if (frames <= 0) {
        kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim);
        return;
    }

    const ChannelPcm& src = channelPcmFor(selectedId_);
    const WaveformSurface surface = waveformSurface(
        band, channelMode_ == ChannelMode::Stereo, src.channelCount);

    if (!surface.upper.empty()) {
        // Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
        // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
        // partition — extra bins produce no visible change. Clamped to frame count below.
        // Both lanes share a width, so one bin count serves both.
        const std::int64_t wantBins =
            static_cast<std::int64_t>(
                (std::max)(1, waveformColumnCount(toKitBox(surface.upper)))) *
            kWaveformOversample;
        const std::size_t bins =
            static_cast<std::size_t>(wantBins < frames ? wantBins : frames);

        // Binned once per (capture, bin count) rather than once per paint — a full scan of the
        // decoded PCM is what a frame-rate repaint cannot afford. See heroEnvelope.
        if (surface.laneCount == 2) {
            // ONE pass over the interleaved source: computeEnvelope already envelopes each
            // channel independently, so the second lane costs no second scan of the PCM.
            const Envelope& env = heroEnvelope(bins, 2);
            drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
            drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
        } else {
            // One lane draws what one lane plays: the downmix, not channel 0 of a stereo
            // source.
            drawEnvelope(bmp, surface.upper, heroEnvelope(bins, 1));
        }
    }

    // Markers and the loop span are overlays: ONE draw across the full stacked height, so a
    // stereo view reads one loop region rather than two.
    const OverlayArea& overlay = surface.overlay;
    const Rect& overlayRect = overlay.rect;
    const bool loopLive = loopControlsLive();
    const bool loopOn = m.hasLoop && loopLive;
    const WaveMarks marks = waveMarksFor(m);
    const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker));

    const int lx = frameToX(overlay, frames, m.loopStart);
    const int rx = frameToX(overlay, frames, m.loopEnd);
    if (loopOn && rx > lx) {
        LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, loopInk,
                      static_cast<float>(kLoopSpanFillAlpha), 0);
    }

    // The crossfade, in the two places it exists: the AUDIBLE region, over the frames the fade
    // actually runs on, and its INGREDIENT — the material one loop length earlier that is being
    // mixed in — as a ghost. Drawing only the ingredient (which is what shipped before) put the
    // one grab affordance on the wrong side of the loop from the sound it controls.
    const int xfadeIdx = static_cast<int>(WaveMark::kCrossfade);
    const bool xfadeHot =
        (drag_ == DragKind::kWaveMarker && waveMarker_ == WaveMark::kCrossfade) ||
        isHovered(HoverKind::kWaveMark, xfadeIdx);
    if (loopOn && m.crossfade > 0) {
        drawCrossfadeWedge(bmp, overlay, frames, m.loopEnd - m.crossfade, m.loopEnd, loopInk,
                           kMarkAlpha, /*filled=*/true);
        // The clamp is crossfade <= min(loopStart, loopLength), and each half is now visible:
        // the ghost's left edge reaches frame 0 exactly at the loopStart bound, and the audible
        // wedge's left edge reaches the LOOP mark exactly at the loopLength bound. The user
        // sees why the fade stopped growing instead of hitting an invisible wall.
        drawCrossfadeWedge(bmp, overlay, frames, m.loopStart - m.crossfade, m.loopStart, loopInk,
                           kGhostAlpha, /*filled=*/xfadeHot);
    }

    // The state caption, centred in the span: the two OFF states say different things because
    // they mean different things, and Trigger's refusal names its own reason.
    const char* caption = nullptr;
    if (!loopLive) caption = "LOOP \xe2\x80\x94 GATE ONLY";  // "LOOP — GATE ONLY" (em dash, UTF-8)
    else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF";
    if (caption != nullptr && rx > lx) {
        // Tight box (kMarkLabelHeight, not the whole overlay) centered on the same midline the
        // full-height rect already centered DT_VCENTER text on, so the scrim darkens only the
        // caption's own row.
        const int capH = kMarkLabelHeight;
        const int capY = overlayRect.y + (overlayRect.height - capH) / 2;
        const Rect capBox = Rect::ltrb(lx, capY, rx, capY + capH);
        scrimLabelBox(bmp, capBox);
        kitTextCentered(bmp, capBox, caption, Font::Micro, Role::TextDim);
    }

    // Labels beneath the trace and the handles in z-order; the promoted one is re-drawn ON TOP
    // after the overlay, so you always see what you grabbed.
    const int promoted =
        (drag_ == DragKind::kWaveMarker)
            ? static_cast<int>(waveMarker_)
            : (hover_.kind == HoverKind::kWaveMark ? hover_.index : -1);
    int labelW[kWaveMarkCount];
    for (int i = 0; i < kWaveMarkCount; ++i) labelW[i] = markLabelWidth(static_cast<WaveMark>(i));
    const WaveMarkLabels labels = layoutMarkLabels(overlay, frames, marks, labelW, promoted);
    for (int i = 0; i < kWaveMarkCount; ++i) {
        if (i == promoted || labels.box[i].empty()) continue;
        scrimLabelBox(bmp, labels.box[i]);
        kitTextCentered(bmp, labels.box[i], markLabel(static_cast<WaveMark>(i)), Font::Micro,
                        Role::TextDim);
    }

    // Line + shaped cap per mark, one grammar. A mark whose gesture is refused draws Disabled
    // rather than hidden — the position is information the user put there.
    for (int i = 0; i < kWaveMarkCount; ++i) {
        if (!marks.present[i]) continue;
        const WaveMark which = static_cast<WaveMark>(i);
        const bool isStart = (which == WaveMark::kStart);
        const bool dim = !isStart && !loopOn;
        const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker)) : loopInk;
        const float alpha = dim ? kMarkAlphaDisabled
                                : (which == WaveMark::kCrossfade ? kMarkAlphaXfade : kMarkAlpha);
        const int mx = frameToX(overlay, frames, marks.frame[i]);
        if (which == WaveMark::kCrossfade) {
            // Dashed: a soft boundary, not a hard one.
            for (int y = overlayRect.y; y < overlayRect.bottom(); y += kDashPeriod) {
                const int h = (std::min)(kDashOn, overlayRect.bottom() - y);
                LICE_FillRect(bmp, mx - 1, y, 2, h, ink, alpha, 0);
            }
        } else {
            LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, ink, alpha, 0);
        }
        drawMarkCap(bmp, which, markerHandleRect(overlay, frames, marks.frame[i]), mx, ink,
                    alpha);
    }

    paintEnvelopeOverlay(bmp, overlay, frames);

    if (promoted >= 0 && promoted < kWaveMarkCount && !labels.box[promoted].empty()) {
        scrimLabelBox(bmp, labels.box[promoted]);
        kitTextCentered(bmp, labels.box[promoted], markLabel(static_cast<WaveMark>(promoted)),
                        Font::Micro, Role::TextPrimary);
    }
}

void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) {
    const VelocityCurve::Box box = splineOverlayBox(waveArea);
    if (box.width <= 0 || box.height <= 1) return;
    const VelocityCurve& curve = splineFor(overlayEnv_);

    // One eval per drawn column, through the curve's own pixel maps, so the trace and the handles
    // share the coordinate system the hit-test resolves against (sub-pixel here — see
    // subpixelFromPoint).
    const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
    std::vector<ui::StrokePoint>& trace = scratchPoints();
    trace.clear();
    // < not <=: box.left + box.width is the overlay's own EXCLUSIVE right edge (the box has no
    // inset, unlike the popup's), so a <= column would re-trace a duplicate vertex one pixel
    // past it. This bound does NOT contain the stroke to the box either way — the round cap on
    // the last vertex extends halfWidth + 0.5 px past it regardless, same as the top/bottom
    // edges the loop never clips against.
    for (int px = 0; px < box.width; ++px) {
        const int cx = box.left + px;
        const double t = curve.pointFromPixel(box, cx, box.top).velocity;
        const auto p = curve.subpixelFromPoint(box, {t, curve.eval(t)});
        trace.push_back(ui::StrokePoint{static_cast<float>(cx), static_cast<float>(p.y)});
    }
    strokePolylineAA(bmp, trace, kEnvTracePx, line);

    // Handles carry two independent states on the same mark, so they use two independent
    // channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower
    // contrast over the lime, see its note), and HOLLOW is hard. The corner itself is the
    // primary hard cue, but a corner between two near-collinear segments has none to show. A
    // grabbed node past the drag-off margin draws WARN — the popup's same "release will
    // delete" cue (editor_paint_curve.cpp), since the two spline surfaces share the drag-off
    // grammar, not just the click grammar.
    const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace));
    const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
    const LICE_pixel core = toLice(roleColor(Role::BgBase));
    const bool dragOffArmed = drag_ == DragKind::kSplineNode &&
                              (dragCurX_ < box.left - kCurveDragOffMargin ||
                               dragCurX_ > box.left + box.width + kCurveDragOffMargin ||
                               dragCurY_ < box.top - kCurveDragOffMargin ||
                               dragCurY_ > box.top + box.height + kCurveDragOffMargin);
    const std::vector<instrument::engine::VelocityPoint>& pts = curve.points();
    for (std::size_t i = 0; i < pts.size(); ++i) {
        const auto np = curve.pixelFromPoint(box, pts[i]);
        const bool grabbed =
            (drag_ == DragKind::kSplineNode && curvePointIndex_ == static_cast<int>(i));
        const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius;
        const int ir = r - kEnvHandleRingPx;
        const int hx = (std::max)(box.left + r,
                                  (std::min)(box.left + box.width - 1 - r, np.x));
        const int hy = (std::max)(box.top + r,
                                  (std::min)(box.top + box.height - 1 - r, np.y));
        const LICE_pixel fill = (grabbed && dragOffArmed) ? handleWarn : handle;
        LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, fill, 1.0f, 0);
        if (pts[i].hard && ir > 0) {
            LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0);
        }
    }
}

void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
                                            std::int64_t frames) {
    if (overlayEnv_ == OverlayEnv::kNone) return;  // no envelope selected is a resting state
    const Rect& area = waveArea.rect;
    if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
    if (overlayIsSpline()) {
        // The contour is a pure function of normalized position, so it needs neither the frame
        // count nor the live rate the staged path below resolves its seconds against.
        paintSplineOverlay(bmp, waveArea);
        return;
    }
    const double rate = liveSampleRate();
    if (rate <= 0.0) return;
    const double totalSeconds = static_cast<double>(frames) / rate;
    const std::int64_t startFrame = params_.startPoint.value_or(0);
    const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
    const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);

    const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
    std::vector<ui::StrokePoint>& trace = scratchPoints();
    buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
    // A degenerate envelope (every stage collapsed to zero span) can reduce this to ONE vertex.
    // strokePolylineAA's round-cap zero-length case then draws a dot at it, marking the sole
    // point rather than drawing nothing — kept deliberately as more legible than a blank trace.
    strokePolylineAA(bmp, trace, kEnvTracePx, line);
    // Handles: a square per draggable stage node, a ROUND knot per curvable segment. Every
    // vertex is guaranteed in-bounds; the handle is additionally clamped inside the band so one
    // on an edge node never overhangs into the neighbouring bands.
    //
    // GRAB is signalled by SIZE + a punched-out core, NOT by a hotter hue — the one place the
    // kit's "brighter = hotter" convention is deliberately inverted, because a brighter tint is
    // a LOWER-contrast tint here: accent/hot sits at 1.15:1 against the lime it is drawn over.
    // The two-neighbour ceiling (core/ui/CLAUDE.md) leaves at most 1.05:1 between ANY two values
    // that both clear the floor over lime and bg/base, so no color can carry this state. The
    // grabbed mark stays overlay/trace and reads by its 3.07:1 ring against the lime plus a
    // 3.06:1 bg/base core inside it.
    const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace));
    const LICE_pixel core = toLice(roleColor(Role::BgBase));
    for (const EnvVertex& v : poly) {
        if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue;
        const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
        const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius;
        const int ir = r - kEnvHandleRingPx;  // core radius; > 0 only when grabbed
        const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x));
        const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y));
        if (v.knot) {
            LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
                            static_cast<float>(r), handle, 1.0f, 0, true);
            if (grabbed)
                LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
                                static_cast<float>(ir), core, 1.0f, 0, true);
        } else {
            LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 0);
            if (grabbed) LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0);
        }
    }
}

}  // namespace reasampler::vst

#endif  // _WIN32
