use NativeCall;
use OpenMPT::Bindings::Module;
use OpenMPT::Bindings::Util;

constant BUF-FRAMES = 1024;
# NativeCall resolves symbols lazily at first call, so Win32-only FFI
# declarations are harmless on Unix — they'll never be touched.
constant IS-WIN = $*DISTRO.is-win;

# ============================================================================
# Win32 terminal API (kernel32)
# ============================================================================

sub GetStdHandle(int32) returns Pointer[void] is native('kernel32') { * }
sub GetConsoleMode(Pointer[void], Pointer[uint32]) returns int32 is native('kernel32') { * }
sub SetConsoleMode(Pointer[void], uint32) returns int32 is native('kernel32') { * }
sub CreateEventW(Pointer[void], int32, int32, Pointer[void]) returns Pointer[void] is native('kernel32') { * }
sub WaitForSingleObject(Pointer[void], uint32) returns uint32 is native('kernel32') { * }
sub CloseHandle(Pointer[void]) returns int32 is native('kernel32') { * }

constant STD_ERROR_HANDLE  = -12;
# Enables \e[4A (cursor up) and \e[0K (clear line) ANSI sequences on
# Windows 10+ terminals, matching the Unix stty-based approach.
constant ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
constant ENABLE_PROCESSED_OUTPUT            = 0x0001;



# ============================================================================
# Win32 audio API (winmm)
# ============================================================================

class WAVEFORMATEX is repr('CStruct') {
    has uint16 $.wFormatTag;
    has uint16 $.nChannels;
    has uint32 $.nSamplesPerSec;
    has uint32 $.nAvgBytesPerSec;
    has uint16 $.nBlockAlign;
    has uint16 $.wBitsPerSample;
    has uint16 $.cbSize;
}

class WAVEHDR is repr('CStruct') {
    has uint64 $.lpData is rw;           # LPSTR (pointer, 8 bytes on x64)
    has uint32 $.dwBufferLength is rw;
    has uint32 $.dwBytesRecorded is rw;
    has uint64 $.dwUser is rw;           # DWORD_PTR
    has uint32 $.dwFlags is rw;
    has uint32 $.dwLoops is rw;
    has uint64 $.lpNext is rw;           # LPWAVEHDR (pointer)
    has uint64 $.reserved is rw;         # DWORD_PTR
}

sub waveOutOpen(Pointer[Pointer[void]] $phwo, uint32 $uDeviceID, Pointer[void] $pwfx,
    Pointer[void] $dwCallback, Pointer[void] $dwInstance, uint32 $fdwOpen) returns uint32 is native('winmm') { * }
sub waveOutPrepareHeader(Pointer[void] $hwo, Pointer[void] $pwh, uint32 $cbwh) returns uint32 is native('winmm') { * }
sub waveOutWrite(Pointer[void] $hwo, Pointer[void] $pwh, uint32 $cbwh) returns uint32 is native('winmm') { * }
sub waveOutUnprepareHeader(Pointer[void] $hwo, Pointer[void] $pwh, uint32 $cbwh) returns uint32 is native('winmm') { * }
sub waveOutClose(Pointer[void] $hwo) returns uint32 is native('winmm') { * }

constant WAVE_FORMAT_PCM = 1;
constant WAVE_MAPPER = 0xFFFFFFFF;
constant CALLBACK_EVENT = 0x00050000;
constant WHDR_DONE = 0x00000001;

# ============================================================================
# PulseAudio Simple API
# ============================================================================

class pa_sample_spec is repr('CStruct') {
    has int32  $.format;
    has uint32 $.rate;
    has uint8  $.channels;
}

constant PA_STREAM_PLAYBACK = 1;
constant PA_SAMPLE_S16LE    = 3;

sub pa_simple_new(Pointer[void], Str, int32, Pointer[void], Str,
    Pointer[pa_sample_spec], Pointer[void], Pointer[void],
    CArray[int32])
    returns Pointer[void] is native('pulse-simple') { * }

sub pa_simple_write(Pointer[void], Pointer[void], size_t, CArray[int32])
    returns int32 is native('pulse-simple') { * }

sub pa_simple_drain(Pointer[void], CArray[int32])
    returns int32 is native('pulse-simple') { * }

sub pa_simple_free(Pointer[void])
    is native('pulse-simple') { * }

# ============================================================================
# ALSA libasound
# ============================================================================

class SndPcmT is repr('CPointer') { }

enum SndPcmStream (
    SND_PCM_STREAM_PLAYBACK => 0,
    SND_PCM_STREAM_CAPTURE  => 1,
);

enum SndPcmFormat (
    SND_PCM_FORMAT_S16_LE => 2,
);

enum SndPcmAccess (
    SND_PCM_ACCESS_RW_INTERLEAVED => 3,
);

sub snd_pcm_open(CArray[SndPcmT] $pcm, Str $name, int32 $stream, int32 $mode)
    returns int32 is native('asound') { * }
sub snd_pcm_set_params(SndPcmT $pcm, int32 $format, int32 $access,
    uint32 $channels, uint32 $rate, int32 $soft_resample, uint32 $latency)
    returns int32 is native('asound') { * }
sub snd_pcm_writei(SndPcmT $pcm, Pointer[void] $buf, size_t $frames)
    returns int64 is native('asound') { * }
sub snd_pcm_recover(SndPcmT $pcm, int32 $err, int32 $silent)
    returns int32 is native('asound') { * }
sub snd_pcm_drain(SndPcmT $pcm)
    returns int32 is native('asound') { * }
sub snd_pcm_close(SndPcmT $pcm)
    returns int32 is native('asound') { * }
sub snd_strerror(int32 $err)
    returns Str is native('asound') { * }

# ============================================================================
# Terminal setup/teardown
# ============================================================================
# On Unix: saves/restores terminal attributes with stty(1).
# On Windows: enables ENABLE_VIRTUAL_TERMINAL_PROCESSING via SetConsoleMode
# so ANSI escape sequences work in the console.

sub setup-terminal(--> Any) {
    if IS-WIN {
        my $errh = GetStdHandle(STD_ERROR_HANDLE);
        return Nil unless $errh.defined && +$errh != 0;
        my $err-mode = CArray[uint32].allocate(1);
        return Nil unless GetConsoleMode($errh, nativecast(Pointer[uint32], $err-mode));

        my $saved = $err-mode[0];

        SetConsoleMode($errh, $saved +| ENABLE_VIRTUAL_TERMINAL_PROCESSING +| ENABLE_PROCESSED_OUTPUT)
            or return Nil;

        return ($errh, $saved);
    } else {
        my $stty-result = run('stty', '-g', :out, :err($*SPEC.devnull));
        return '' unless $stty-result.exitcode == 0;
        my $saved = $stty-result.out.slurp(:close).trim;
        return '' unless $saved.chars;
        run('stty', '-icanon', '-echo', '-echoe', '-echok', :err($*SPEC.devnull));
        return $saved;
    }
}

sub restore-terminal(Any $saved) {
    return unless $saved.defined;
    if IS-WIN {
        my ($errh, $mode) = $saved ~~ List ?? flat($saved) !! ();
        SetConsoleMode($errh, $mode) if $errh.defined && $mode.defined;
    } else {
        run('stty', $saved, :err($*SPEC.devnull)) if $saved.chars;
    }
}

# Mutable state container for one playback session.
# Each backend creates its own instance to avoid shared globals.
class DisplayState {
    has Bool    $.vu-first         is rw = True;
    has Int     $.vu-last-redraw   is rw;
    has Numeric $.vu-hold-l        is rw = 0e0;
    has Numeric $.vu-hold-r        is rw = 0e0;
    has Int     $.vu-last-rendered is rw;
    has Str     $.gain-cache       is rw;
    has Str     $.stereo-cache     is rw;
    has Str     $.filter-cache     is rw;
    has Str     $.ramping-cache    is rw;
    has Numeric $.duration-cache   is rw;
}

# ============================================================================
# Audio backends
# ============================================================================
# Each backend takes (module, sample-rate, max-frames, &status-callback).
# $max-frames is 0 for unlimited, or a hard frame cap from --seconds.
# They return 0 on success or -1 on failure.
# waveOut uses triple-buffering with spin-wait slot recycling.

sub play-waveout(OpenMPT::Bindings::Module $mod, Int $rate, Int $max-frames, $update-status --> Int) {
    my $ds = DisplayState.new;
    my $h-wave-int = CArray[uint64].allocate(1);
    my $wfx = WAVEFORMATEX.new(
        :wFormatTag(WAVE_FORMAT_PCM), :nChannels(2),
        :nSamplesPerSec($rate), :wBitsPerSample(16),
        :nBlockAlign(4), :nAvgBytesPerSec($rate * 4),
    );
    my $phwo = nativecast(Pointer[Pointer[void]], $h-wave-int);
    my $hEvent = CreateEventW(Pointer[void], 0, 0, Pointer[void]);
    return -1 unless $hEvent.defined && +$hEvent != 0;
    my $rc = waveOutOpen($phwo, WAVE_MAPPER, nativecast(Pointer[void], $wfx),
        nativecast(Pointer[void], $hEvent), Pointer[void], CALLBACK_EVENT);
    return -1 if $rc != 0;
    my $hwo-addr = $h-wave-int[0];
    return -1 if $hwo-addr == 0;
    my $hwo = Pointer[void].new($hwo-addr);
    LEAVE { CloseHandle($hEvent); waveOutClose($hwo) }

    constant WAVEOUT-BUF = IS-WIN ?? 8 !! 3;
    my @buf = (^WAVEOUT-BUF).map: { CArray[int16].allocate(BUF-FRAMES * 2) }
    my @wh  = (^WAVEOUT-BUF).map: -> $i {
        WAVEHDR.new(
            :lpData(nativecast(Pointer[void], @buf[$i]).Int),
            :dwBufferLength(BUF-FRAMES * 4),
        )
    }
    my @inflight = False xx WAVEOUT-BUF;
    my $slot = 0;

    my sub write(Int $got, Pointer[void] $cbuf --> Int) {
        if @inflight[$slot] {
            until @wh[$slot].dwFlags +& WHDR_DONE {
                WaitForSingleObject($hEvent, 0xFFFFFFFF)
            }
            waveOutUnprepareHeader($hwo, nativecast(Pointer[void], @wh[$slot]), nativesizeof(WAVEHDR));
            @inflight[$slot] = False;
        }

        my $bytes = $got * 4;
        memcpy(nativecast(Pointer[void], @buf[$slot]), $cbuf, $bytes);

        @wh[$slot].dwBufferLength = $bytes;
        @wh[$slot].dwFlags = 0;
        @wh[$slot].dwBytesRecorded = 0;
        @inflight[$slot] = True;
        waveOutPrepareHeader($hwo, nativecast(Pointer[void], @wh[$slot]), nativesizeof(WAVEHDR));
        waveOutWrite($hwo, nativecast(Pointer[void], @wh[$slot]), nativesizeof(WAVEHDR));

        $slot = ($slot + 1) % WAVEOUT-BUF;
        0;
    }

    LEAVE {
        for ^WAVEOUT-BUF -> $i {
            if @inflight[$i] {
                until @wh[$i].dwFlags +& WHDR_DONE { sleep(0) }
                waveOutUnprepareHeader($hwo, nativecast(Pointer[void], @wh[$i]), nativesizeof(WAVEHDR));
            }
        }
    }

    play-loop($mod, $rate, $max-frames, $ds, &write, $update-status);
}

sub play-pulse(OpenMPT::Bindings::Module $mod, Int $rate, Int $max-frames, &update-status --> Int) {
    my $ds = DisplayState.new;
    my $ss = pa_sample_spec.new(:format(PA_SAMPLE_S16LE), :rate($rate), :channels(2));
    my $err = CArray[int32].allocate(1);
    my $pa = pa_simple_new(Pointer[void], 'OpenMPT', PA_STREAM_PLAYBACK, Pointer[void], 'Audio',
        nativecast(Pointer[pa_sample_spec], $ss),
        Pointer[void], Pointer[void], $err);
    return -1 unless $pa;
    LEAVE { pa_simple_free($pa) if $pa }

    my sub write(Int $got, Pointer[void] $cbuf --> Int) {
        pa_simple_write($pa, $cbuf, $got * 4, $err) == 0 ?? 0 !! -1;
    }
    my $rc = play-loop($mod, $rate, $max-frames, $ds, &write, &update-status);
    pa_simple_drain($pa, $err) if $rc == 0;
    $rc
}

sub play-alsa(OpenMPT::Bindings::Module $mod, Int $rate, Int $max-frames, &update-status --> Int) {
    my $ds = DisplayState.new;
    my $pcm-arr = CArray[SndPcmT].allocate(1);
    my $rc = snd_pcm_open($pcm-arr, 'default', SND_PCM_STREAM_PLAYBACK, 0);
    if $rc < 0 {
        note "ALSA open failed: { snd_strerror($rc) }";
        return -1;
    }
    my $pcm = $pcm-arr[0];
    # LEAVE runs on both set_params success and failure; drain on an unconfigured
    # PCM is a harmless no-op error, so close is the only meaningful action here.
    LEAVE { snd_pcm_drain($pcm); snd_pcm_close($pcm) }

    $rc = snd_pcm_set_params($pcm, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED,
        2, $rate, 1, 500_000);
    if $rc < 0 {
        note "ALSA set_params failed: { snd_strerror($rc) }";
        return -1;
    }

    my sub write(Int $got, Pointer[void] $cbuf --> Int) {
        alsa-write-all($pcm, nativecast(Pointer[int16], $cbuf), $got)
    }
    play-loop($mod, $rate, $max-frames, $ds, &write, &update-status);
}

# Shared render loop — thin wrapper around render-core for playback backends.
# The write-chunk signature matches legacy backends (Pointer[void], returns Int).
sub play-loop(
    OpenMPT::Bindings::Module $mod, Int $rate, Int $max-frames,
    DisplayState $ds,
    &write-chunk:(Int $got, Pointer[void] $cbuf --> Int),
    $update-status,
    --> Int
) {
    my sub write(Int $got, Pointer[void] $p, Int $bytes --> Bool) {
        write-chunk($got, $p) >= 0
    }
    render-core($mod, $rate, $max-frames, &write, &update-status, $ds, :prefill-buffers(3)) >= 0 ?? 0 !! -1
}

my %BACKENDS =
    pulse   => &play-pulse,
    alsa    => &play-alsa,
    waveout => &play-waveout,
;

# Partial-write recovery for ALSA: loop until all frames are written
sub alsa-write-all(SndPcmT $pcm, Pointer[int16] $buf, Int $frames --> Int) {
    my $offset = 0;
    while $offset < $frames {
        my $got = snd_pcm_writei($pcm, nativecast(Pointer[void], $buf.add($offset)), $frames - $offset);
        if $got < 0 {
            $got = snd_pcm_recover($pcm, $got.Int, 1);
            if $got < 0 {
                return -1;  # unrecoverable error
            }
            if $got == 0 {
                sleep(0.001);  # recovery succeeded but wrote 0 — device may need time
                next;
            }
        }
        next if $got == 0;  # snd_pcm_writei returned 0 with no error — retry same offset
        $offset += $got;
    }
    0;
}

# ============================================================================
# VU meter helpers
# ============================================================================

sub peaks-from-int16(Pointer[void] $cbuf, Int $frames --> List) {
    my int $peakL = 0;
    my int $peakR = 0;
    my int $n = $frames * 2;
    my Pointer[int16] $p = nativecast(Pointer[int16], $cbuf);
    my int $i = 0;
    while $i < $n {
        my int $l = $p[$i];
        my int $r = $p[$i+1];
        $l = -$l if $l < 0;
        $r = -$r if $r < 0;
        $peakL = $l if $l > $peakL;
        $peakR = $r if $r > $peakR;
        $i += 2;
    }
    ($peakL / 32768e0, $peakR / 32768e0);
}

# ============================================================================
# Shared render loop
# ============================================================================
# Used by both play-loop (live playback backends) and render-wav (WAV export).
# write-frames returns True to continue, False to abort the loop.
# $prewarm=True skips timing on the first iteration to avoid a meaningless spike.

sub render-core(
    OpenMPT::Bindings::Module $mod, Int $rate, Int $max-frames,
    &write-frames:(Int $got, Pointer[void] $cbuf, Int $bytes --> Bool),
    &update-status,
    DisplayState $ds = DisplayState.new,
    Bool :$prewarm = True,
    Int  :$prefill-buffers = 0,
    --> Int
) {
    my $cpu-smooth = 0e0;
    my $rendered = 0;
    my $first = $prewarm;

    # Pre-fill the audio device buffer to avoid underrun at playback start.
    # Writes $prefill-buffers buffers (~85 ms each at 48 kHz) silently before
    # the status/timing loop, warming up CArray allocation, NativeCall
    # trampolines, and the hardware buffer in one pass.
    my $prefill = $prefill-buffers;
    while $prefill > 0 {
        my $remaining = $max-frames > 0 ?? $max-frames - $rendered !! BUF-FRAMES;
        last if $remaining <= 0;
        my $count = min(BUF-FRAMES, $remaining);
        my ($got, $p, $bytes) = $mod.read-interleaved-stereo-raw($rate, $count);
        last unless $got;
        return -1 unless write-frames($got, $p, $bytes);
        $rendered += $got;
        $prefill--;
    }

    # Pre-fill has already warmed up cold caches (CArray allocation,
    # NativeCall trampolines), so the first main-loop iteration
    # timing is meaningful — skip $prewarm if we actually pre-filled.
    $first = False if $prefill-buffers;

    while True {
        my $count = $max-frames > 0 ?? min(BUF-FRAMES, $max-frames - $rendered) !! BUF-FRAMES;
        last if $count <= 0;
        my $read-start = $first ?? 0 !! now;
        my ($got, $p, $bytes) = $mod.read-interleaved-stereo-raw($rate, $count);
        my $read-time = $first ?? 0 !! now - $read-start;
        $first = False;
        last unless $got;
        my ($peakL, $peakR) = peaks-from-int16($p, $got);
        return -1 unless write-frames($got, $p, $bytes);
        $rendered += $got;
        if $read-time {
            my Num $mix = ($got / $rate).Num;
            my Num $inst = $read-time / $mix;
            $cpu-smooth = (1 - $mix) * $cpu-smooth + $mix * $inst;
        }
        &update-status($ds, $mod, $rendered, $rate, $cpu-smooth * 100, $peakL, $peakR);
    }
    $rendered
}

sub vu-line(Str $tag, Numeric $peak, Numeric $hold --> Str) {
    my int $val = $peak > 0 ?? (20 * log($peak, 10) + 48).Int !! 0;
    $val = 0 if $val < 0;
    $val = 48 if $val > 48;
    my $bar = '>' x $val ~ ' ' x (48 - $val);
    if $hold > 0 {
        my int $h = (20 * log($hold, 10) + 48).Int;
        $h = 0 if $h < 0;
        $h = 47 if $h > 47;  # 47, not 48 — leave room for peak indicator at rightmost position
        $bar.substr-rw($h, 1) = ':';
    }
    sprintf("        %s : %s%s", $tag, $bar, $peak >= 1.0 ?? '#' !! ':');
}

# ============================================================================
# Status display (shared across all backends)
# ============================================================================
# Uses \e[6A to move cursor up 6 lines and \e[0K to clear each line,
# creating an openmpt123-style live-updating 6-line status panel with VU meter.
# Throttled to \$r/20 frames (~50 Hz at 48 kHz).

sub update-status(DisplayState $ds, OpenMPT::Bindings::Module $m, Int $rendered, int32 $r, Numeric $cpu-pct, Numeric $peakL = 0, Numeric $peakR = 0) {
    my $rendered-sec = $rendered / $r;

    # Auto-reset on module transition: rendered wrapped from high to low
    # Throttle: skip redraw if < ~50 Hz (roughly $r/20 frames at rate $r)
    return if $ds.vu-last-redraw.defined && $rendered < $ds.vu-last-redraw + $r div 20;
    $ds.vu-last-redraw = $rendered;

    # Cache render params (unchanged at runtime in this player)
    $ds.gain-cache   //= try { ($m.mastergain / 100).fmt('%.1f') } // '?';
    $ds.stereo-cache //= try { $m.stereo-separation.Str }          // '?';
    $ds.filter-cache  //= try { $m.interpolation-filter-length.Str } // '?';
    $ds.ramping-cache //= try { ($m.volume-ramping-strength == -1 ?? 'default' !! $m.volume-ramping-strength.Str) }    // '?';

    # Dynamic getters: re-read every redraw
    my $gain    = $ds.gain-cache;
    my $stereo  = $ds.stereo-cache;
    my $filter  = $ds.filter-cache;
    my $ramping = $ds.ramping-cache;
    my $chans   = try { $m.current-playing-channels }            // '?';
    my $order   = try { $m.current-order }                      // '?';
    my $pattern = try { $m.current-pattern }                    // '?';
    my $row     = try { $m.current-row }                        // '?';
    my $speed   = try { $m.current-speed }                      // '?';
    my $tempo   = try { sprintf('%.2f', $m.current-tempo) }     // '?';
    $ds.duration-cache //= try { $m.duration } // 0;
    my $pos     = try { $m.position } // $rendered-sec;
    $pos = min($pos, $ds.duration-cache) if $ds.duration-cache > 0;
    my $norders = try { $m.num-orders } // '?';

    # Right-align with ':' fill, matching openmpt123 layout:
    #   Ord::11/::64 Pat:::3 Row::10   Spd:6 Tmp:125.00
    my $cpu-str  = pad(sprintf('%.2f%%', $cpu-pct), 6);
    my $chn-str  = pad($chans, 3);
    my $ord-str  = pad($order, 3);
    my $nord-str = pad($norders, 3);
    my $pat-str  = pad($pattern, 3);
    my $row-str  = pad($row, 3);
    my $spd-str  = pad($speed, 2);

    # VU peak-hold with exponential decay (~11.76 dB/sec)
    my $df = $rendered - ($ds.vu-last-rendered // $rendered);
    if $df > 0 {
        my $falloff = 10 ** (-$df / (1.7 * $r));
        $ds.vu-hold-l *= $falloff;
        $ds.vu-hold-r *= $falloff;
    }
    $ds.vu-hold-l = $peakL if $peakL > $ds.vu-hold-l;
    $ds.vu-hold-r = $peakR if $peakR > $ds.vu-hold-r;
    $ds.vu-last-rendered = $rendered;

    my $vuL = vu-line(' L', $peakL, $ds.vu-hold-l);
    my $vuR = vu-line(' R', $peakR, $ds.vu-hold-r);

    my $esc = '';
    if !$ds.vu-first {
        $esc = "\e[6A";
    }
    $ds.vu-first = False;

    $*ERR.print(
        $esc
        ~ $vuL ~ "\e[0K\n"
        ~ $vuR ~ "\e[0K\n"
        ~ sprintf("Settings...: Gain: %s dB   Stereo: %s %%   Filter: %s taps   Ramping: %s   \e[0K\n", $gain, $stereo, $filter, $ramping)
        ~ sprintf("Mixer......: CPU:%s   Chn:%s   \e[0K\n", $cpu-str, $chn-str)
        ~ sprintf("Player.....: Ord:%s/%s Pat:%s Row:%s   Spd:%s Tmp:%s\e[0K\n", $ord-str, $nord-str, $pat-str, $row-str, $spd-str, $tempo)
        ~ sprintf("Position...: %s / %s\e[0K\n",
            fmt-time($pos),
            fmt-time($ds.duration-cache))
    );
    $*ERR.flush;
}

# ============================================================================
# MAIN
# ============================================================================

multi sub MAIN(Bool :$list-extensions!) {
    .say for OpenMPT::Bindings::Module.supported-extensions.sort;
}

multi sub MAIN(
    *@files,
    Bool  :$info,
    Bool  :$render-wav,
    Int   :$subsong  = 0,
    Int   :$repeat   = 0,
    Numeric :$seek   = 0,
    Numeric :$seconds = 0,
    Numeric :$gain    = 0,
    Numeric :$tempo   = 1.0,
    Int   :$rate     = 48000,
    Str   :$backend,
) {
    die "No input files" unless @files;

    for @files -> $path {
        next unless $path.IO.e;

        my $mod = try OpenMPT::Bindings::Module.from-file($path);
        if $! {
            note "Skipping $path: { $!.message }";
            next;
        }

        note "\n--- { $path.IO.basename } ---" if @files > 1;

        if $info {
            show-info($mod, $path);
            next;
        }

        $mod.select-subsong($subsong);
        $mod.set-repeat-count($repeat);
        $mod.set-position($seek) if $seek > 0;
        $mod.set-mastergain(($gain * 100).Int) if $gain != 0;
        $mod.set-ctl('play.tempo_factor', $tempo.Num) if $tempo != 1.0;

        show-header($mod, $path);

        # --render-wav renders a WAV file without playback
        if $render-wav {
            render-wav($mod, $rate, $path, $seconds);
            next;
        }

        # Only cap playback length when --seconds is explicitly given
        my $max-frames = $seconds > 0 ?? ($rate * $seconds).Int !! 0;

        # Select audio backend: user-specified or platform default
        my $bname = $backend.defined ?? $backend !! (IS-WIN ?? 'waveout' !! 'pulse');
        my $cb = %BACKENDS{$bname};
        unless $cb.defined {
            note "Unknown backend '$bname'; try pulse, alsa, or waveout";
            next;
        }
        note "Audio backend: $bname";

        my $saved-terminal = setup-terminal;
        my $orig-err-buf = $*ERR.out-buffer;
        $*ERR.out-buffer = 0;  # zero-byte buffer size disables output buffering
        $*ERR.flush;

        # On scope exit: keep status visible, restore terminal
        LEAVE {
            $*ERR.print("\n");
            $*ERR.flush;
            $*ERR.out-buffer = $orig-err-buf;
            restore-terminal($saved-terminal);
        }

        my &update = &update-status;
        my $rc = $cb($mod, $rate, $max-frames, &update);
        if $rc < 0 && !$backend.defined && $bname eq 'pulse' {
            note "PulseAudio unavailable, falling back to alsa";
            # play-pulse only fails at pa_simple_new (before any frames are
            # consumed), so resetting to the initial seek point is correct.
            $mod.set-position($seek > 0 ?? $seek !! 0);
            $rc = %BACKENDS<alsa>($mod, $rate, $max-frames, &update);
        }
        if $rc < 0 {
            note "Playback failed — try --render-wav to render to WAV instead";
        }
    }
}

# ============================================================================
# Info display
# ============================================================================

sub print-line(Str $label, $value) {
    my $padded = ($label ~ '.' x max(0, 11 - $label.chars) ~ ':');
    printf "%-13s %s\n", $padded, $value // '';
}

sub show-info(OpenMPT::Bindings::Module $mod, Str $path, Bool :$header = False) {
    print-line('Filename', $path.IO.basename);
    print-line('Size', humansize($path.IO.s));
    print-line('Type', sprintf('%s (%s)', $mod.type, $mod.type-long // ''));
    print-line('Tracker', $mod.tracker // '(none)');
    print-line('Title', $mod.title // '(none)');
    print-line('Duration', fmt-time($mod.duration));
    print-line('Subsongs', $mod.num-subsongs);
    print-line('Channels', $mod.num-channels);
    print-line('Orders', $mod.num-orders);
    print-line('Patterns', $mod.num-patterns);
    print-line('Instruments', $mod.num-instruments);
    print-line('Samples', $mod.num-samples);

    if $header {
        note '';
        return;
    }

    my $warn = $mod.warnings-str;
    if $warn.defined && $warn.chars {
        print-line('Warnings', $warn);
    }
    my $msg = $mod.module-message;
    if $msg.defined && $msg.chars {
        note "\nMessage:\n$msg";
    }
}

sub show-header(OpenMPT::Bindings::Module $mod, Str $path) {
    show-info($mod, $path, :header);
}

# Right-align with ':' fill, e.g. pad(3, 4) → ":::3"
sub pad($s, Int $w --> Str) {
    my $str = $s.Str;
    $str.chars >= $w ?? $str !! ':' x ($w - $str.chars) ~ $str
}

# ============================================================================
# WAV rendering
# ============================================================================

sub render-wav(OpenMPT::Bindings::Module $mod, int32 $rate, Str $path, Numeric $max-seconds = 0) {
    my $ds = DisplayState.new;
    my $outpath = $path.IO.extension('wav').absolute;
    note "Overwriting: $outpath" if $outpath.IO.e;
    my $fh = $outpath.IO.open(:w, :bin);
    LEAVE .close with $fh;

    # Write placeholder header; we'll seek back and fix sizes after rendering.
    $fh.write(wav-header($rate, 0));

    my $max-frames = $max-seconds > 0 ?? ($rate * $max-seconds).Int !! 0;
    my sub write-frames(Int $got, Pointer[void] $p, Int $bytes --> Bool) {
        $fh.write(pointer-to-blob($p, $bytes)) or return False;
        True
    }
    my sub silent-status($ds, $, $, $, $, $, $ --> Nil) { }
    my $rendered = render-core($mod, $rate, $max-frames, &write-frames, &silent-status, $ds);
    if $rendered < 0 {
        note "WAV rendering failed";
        return;
    }

    $*ERR.print("\n");
    $*ERR.flush;

    # Seek back and patch sizes in header
    my $data-size = $rendered * 4;
    $fh.seek(0, SeekFromBeginning);
    $fh.write(wav-header($rate, $data-size));

    note "Wrote {$outpath.IO.basename}: {($data-size / 1024 / 1024).fmt('%.1f')} MiB, {$rendered} frames";
}

# ============================================================================
# Formatting helpers
# ============================================================================

sub humansize(Int $bytes --> Str) {
    constant KiB = 1024;
    constant MiB = 1024 * KiB;
    constant GiB = 1024 * MiB;
    my $b = $bytes;
    given $b {
        when * < KiB   { sprintf '%.0f B', $b }
        when * < MiB   { sprintf '%.0f KiB', $b / KiB }
        when * < GiB   { sprintf '%.1f MiB', $b / MiB }
        default        { sprintf '%.1f GiB', $b / GiB }
    }
}
