"""loops — the loop finder (task #5).

Generate ranked loop candidates from a separated stem, per the phase-2 design
(research/PHASE2_FINDINGS §1, §3, §4). The pipeline, all librosa/scipy/numpy:

  1. beat grid — drift-tolerant PLP (predominant local pulse), NOT beat_track.
  2. beat-synchronous SSM — MFCC(13) ⊕ chroma(12) synced to beats →
     recurrence affinity. The beat axis absorbs setcps drift for free.
  3. Foote checkerboard novelty on the beat-sync SSM (1-bar and 4-bar kernels).
  4. candidate windows at {1,2,4,8} bars between novelty peaks.
  5. score each window (§3 composite) and dedup.
  6. zero-crossing snap at export.

The finder works PER STEM (§0): 1..x ranked candidates each, then loose cross-
stem grouping into Takes (a time window the human auditions together). The
loopability score REUSES the grader's seam/zc sub-scorers (engine.grade, DRY) and
adds the two terms only the finder has context for: structural (does the window
repeat in the SSM?) and boundary novelty (clean segment edges).

Suggestions are never auto-applied; a wrong rank costs one click to ignore.

    python3 -m engine.loops <stem.wav> [--bars 2 4 8] [--top 8]   # rank one stem
"""
from __future__ import annotations

import math
from pathlib import Path
from typing import Callable, Optional

import numpy as np
import soundfile as sf
from pydantic import BaseModel, Field

from . import grade as G
from .model import Catch

BEATS_PER_BAR = 4
EXPORT_SR = 44100                 # B8: SuperDirt/SC rate
ZC_TOL_MS = 10.0                  # B1
XFADE_MS = 20.0                   # B3 (3 ms for drums, applied at export)

# §3 composite weights (provisional; calibrate with #7)
W = {"struct": 0.4, "novel": 0.2, "seam": 0.3, "zc": 0.1, "tempo_penalty": 0.2}

# verify-rerank (task #19): the finder scores the seam on the RAW window, but the
# exported loop is zero-crossing-snapped (B1) — and the snap changes the wrap. On the
# Super freak dub kit several proxy-seam≈1.0 windows CLICKED after the snap (post-snap
# grade C, click_db 15–20) while genuinely seamless windows ranked below them. The
# verify pass re-measures the TRUE post-snap seam on surviving candidates and folds it
# into the rank so the finder's order matches the grader (grade.py) ground truth.
VERIFY_SEAM_FLOOR = 0.15          # true post-snap seam below this ⇒ hard-demote a click
VERIFY_SURVIVOR_MULT = 3          # verify ~3× top_n survivors, then cut to top_n


class LoopCandidate(BaseModel):
    """One scored window within a single stem."""
    start_s: float
    end_s: float
    bars: int
    bpm: float
    tempo_unstable: bool
    score: float
    structural: float
    novelty: float
    seam: float
    zc: float


class StemSlice(BaseModel):
    name: str                     # drums|bass|other|vocals
    start_s: float
    end_s: float
    score: float
    rms_dbfs: float
    is_one_shot: bool


class Take(BaseModel):
    """A bar-window = the unit PLN selects; gathers each stem's slice in it."""
    start_s: float
    end_s: float
    bars: int
    bpm: float
    tempo_unstable: bool
    score: float                  # best stem score in the window
    n_stems: int
    stems: list[StemSlice] = Field(default_factory=list)


# ── pipeline ──────────────────────────────────────────────────────────────────
def beat_grid(y: np.ndarray, sr: int, *, tempo_min=70.0, tempo_max=160.0, hop=512):
    """Drift-tolerant beat times + per-beat local BPM via PLP. (frames, times, bpm).

    PLP's tempo range is pinned to one musical octave and peak-picking enforces a
    minimum inter-beat spacing — without this PLP latches onto the eighth-note
    pulse and reports ~2× tempo (the §7 octave hazard; Loituma read as 250 not 125).
    """
    import librosa
    oenv = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop)
    pulse = librosa.beat.plp(onset_envelope=oenv, sr=sr, hop_length=hop,
                             tempo_min=tempo_min, tempo_max=tempo_max)
    from scipy.signal import find_peaks
    min_dist = max(1, int((60.0 / tempo_max) * sr / hop))
    peaks, _ = find_peaks(pulse, distance=min_dist)
    times = librosa.frames_to_time(peaks, sr=sr, hop_length=hop)
    if len(times) >= 2:
        ibi = np.diff(times)
        bpm = 60.0 / np.clip(ibi, 1e-3, None)
        bpm = np.r_[bpm, bpm[-1]]
    else:
        bpm = np.array([120.0] * len(times))
    return peaks, times, bpm


def beat_sync_ssm(y: np.ndarray, sr: int, beat_frames: np.ndarray) -> np.ndarray:
    """MFCC(13) ⊕ chroma_cqt(12) synced onto the beat grid → recurrence affinity."""
    import librosa
    mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
    chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
    feat = np.vstack([librosa.util.normalize(mfcc, axis=1),
                      librosa.util.normalize(chroma, axis=1)])
    synced = librosa.util.sync(feat, beat_frames, aggregate=np.mean)
    if synced.shape[1] < 3:
        return np.zeros((synced.shape[1], synced.shape[1]))
    return librosa.segment.recurrence_matrix(synced, mode="affinity", sym=True)


def _checkerboard(k: int) -> np.ndarray:
    """Gaussian-tapered checkerboard kernel (2k×2k) for Foote novelty."""
    g = np.outer(*[np.exp(-0.5 * (np.linspace(-2, 2, 2 * k)) ** 2)] * 2)
    sign = np.ones((2 * k, 2 * k))
    sign[:k, k:] = sign[k:, :k] = -1
    return g * sign


def foote_novelty(ssm: np.ndarray, k: int) -> np.ndarray:
    """Checkerboard-kernel novelty along the SSM diagonal; peaks = segment edges."""
    N = ssm.shape[0]
    if N < 2 * k + 1:
        return np.zeros(N)
    ker = _checkerboard(k)
    nov = np.zeros(N)
    pad = np.pad(ssm, k, mode="edge")
    for i in range(N):
        nov[i] = float(np.sum(pad[i:i + 2 * k, i:i + 2 * k] * ker))
    nov = np.maximum(nov, 0)
    return nov / (nov.max() + 1e-9)


def _structural(ssm: np.ndarray, s: int, e: int) -> float:
    """Does the window [s,e) recur elsewhere? Mean best off-segment affinity."""
    if e - s < 1 or ssm.shape[0] < 3:
        return 0.0
    block = ssm[s:e].copy()
    block[:, s:e] = 0.0                       # mask its own diagonal band
    return float(block.max(axis=1).mean()) if block.size else 0.0


def _snapped_window(y: np.ndarray, s_smp: int, e_smp: int, sr: int) -> np.ndarray:
    """The exact slice export_take would write: length-preserving zc-snap (B1).

    Mirrors export_take's snap so the verify-rerank measures the window that actually
    ships. The length is held rigid at (e_smp − s_smp) and the whole window slides by one
    common offset onto near-zero edges — identical scheme to `_snap_length_preserving`, so
    duration is preserved and the seam read is export-faithful.
    """
    length = e_smp - s_smp
    a, b = _snap_length_preserving(y, s_smp, sr, length)
    if b <= a:                                # degenerate snap → fall back to raw bounds
        a, b = s_smp, e_smp
    return y[a:b]


def _true_seam(y: np.ndarray, s_smp: int, e_smp: int, sr: int) -> float:
    """TRUE post-snap seam quality (0..1) of a window, via the grader's seam scorer.

    Reuses grade.seam_score (DRY — no duplicated DSP) on the zc-snapped slice that will
    actually be exported, so the finder's seam term is the same quantity the grader
    reports rather than a raw-window proxy that the snap invalidates.
    """
    snapped = _snapped_window(y, s_smp, e_smp, sr)
    if snapped.size < 3:
        return 1.0
    seam, _ = G.seam_score(snapped)
    return seam


def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
                 grid=None, weights=None, verify=True) -> list[LoopCandidate]:
    """Rank loop candidates within one stem.

    `grid` = a shared (peaks, times, bpm) beat grid (e.g. from the drums stem). Passing
    it fixes the §7 finding that PLP on sparse-onset stems (vocals) yields a weak grid
    and poor windows: derive the grid once from a rhythmic source and reuse it here so
    every stem is cut on the same musical bar lines. None ⇒ compute per-stem (CLI case).

    `weights` = optional override of the §3 composite weights W (struct/novel/seam/zc/
    tempo_penalty); None ⇒ module defaults. The autotune harness sweeps these.

    `verify` (default True) = run the verify-rerank pass: after candidate generation the
    seam term of the ~3× top_n survivors is REPLACED by the true post-snap seam (measured
    on the zero-crossing-snapped window that export actually writes), and candidates whose
    true seam falls below VERIFY_SEAM_FLOOR are hard-demoted. This closes the divergence
    where a proxy-seam≈1.0 window clicked after the B1 snap (see module note). Set False
    to reproduce the pre-#19 raw-proxy ranking (autotune baselines).
    """
    w = W if weights is None else {**W, **weights}
    peaks, times, bpm = grid if grid is not None else beat_grid(y, sr)
    nb = len(times)
    if nb < BEATS_PER_BAR * min(bars) + 1:
        return []
    ssm = beat_sync_ssm(y, sr, peaks)         # peaks are onset-envelope frames
    n = ssm.shape[0]
    nov = np.maximum(foote_novelty(ssm, 2), foote_novelty(ssm, 8)) if n else np.zeros(nb)

    cands: list[LoopCandidate] = []
    for B in bars:
        W_beats = B * BEATS_PER_BAR
        for i in range(0, nb - W_beats):
            e = i + W_beats
            if e >= n:
                continue
            s_smp, e_smp = int(times[i] * sr), int(times[e] * sr)
            if e_smp - s_smp < int(0.5 * sr):
                continue
            sl = y[s_smp:e_smp]
            if sl.size < 8:
                continue
            seam, _ = G.seam_score(sl)
            zc, _, _ = G.zc_score(sl, sr)
            struct = _structural(ssm, i, e)
            novel = float((nov[i] + nov[min(e, n - 1)]) / 2)
            win_bpm = bpm[i:e]
            bmean = float(np.mean(win_bpm)); bspread = float(np.std(win_bpm))
            unstable = bspread > 5.0
            tempo_pen = min(1.0, bspread / 20.0)
            score = (w["struct"] * struct + w["novel"] * novel +
                     w["seam"] * seam + w["zc"] * zc - w["tempo_penalty"] * tempo_pen)
            cands.append(LoopCandidate(
                start_s=round(times[i], 3), end_s=round(times[e], 3), bars=B,
                bpm=round(bmean, 1), tempo_unstable=unstable,
                score=round(max(0.0, score), 4), structural=round(struct, 4),
                novelty=round(novel, 4), seam=round(seam, 4), zc=round(zc, 4)))

    if verify:
        cands = _verify_rerank(cands, y, sr, w, top_n)
    return _dedup(cands, top_n)


def _verify_rerank(cands: list[LoopCandidate], y: np.ndarray, sr: int,
                   w: dict, top_n: int) -> list[LoopCandidate]:
    """Re-score the top survivors' seam on the true post-snap window (see analyze_stem).

    Cheap: only ~VERIFY_SURVIVOR_MULT×top_n highest-proxy candidates are re-measured, each
    on a small already-in-memory slice. For each, the proxy seam term is swapped for the
    true post-snap seam (rebuilding the composite with the same weights), and a candidate
    whose true seam < VERIFY_SEAM_FLOOR is hard-demoted below all verified-clean ones so a
    click can never top the list. Unverified candidates keep their proxy score.

    Bounds come from the candidate's ROUNDED start_s/end_s — the exact values export_take
    slices at — so verify measures the window that ships, not the sub-sample-exact one
    (the 3-dp rounding alone can shift the wrap onto a click: a beat-exact 0.79 seam read
    0.07 once rounded, and that rounded slice is what gets written).
    """
    survivors = sorted(cands, key=lambda x: -x.score)[:VERIFY_SURVIVOR_MULT * top_n]
    for c in survivors:
        s_smp, e_smp = int(c.start_s * sr), int(c.end_s * sr)
        true_seam = _true_seam(y, s_smp, e_smp, sr)
        # rebuild the composite swapping proxy seam → true seam (same weights)
        base = c.score - w["seam"] * c.seam + w["seam"] * true_seam
        if true_seam < VERIFY_SEAM_FLOOR:     # audible wrap click after snap
            base -= 1.0                       # hard-demote below any clean candidate
        c.seam = round(true_seam, 4)
        c.score = round(max(0.0, base) if true_seam >= VERIFY_SEAM_FLOOR else base, 4)
    return cands


def analyze_chops(y: np.ndarray, sr: int, *, top_n=12, grid=None,
                  min_len_s=0.25, max_len_s=2.0) -> list[LoopCandidate]:
    """Onset-driven SUB-BAR candidates — how PLN actually chops vocal/sample sources.

    The bar-aligned finder (analyze_stem) targets 2/4/8-bar loops, but the corpus GT is
    chop-heavy: short, often sub-bar pieces (word chops, stabs, one-shots) cut between
    transients (#18 pt.2, the rest of the recall gap). This segments by onset and offers
    onset→onset slices (≤ max_len_s), scored on the seam/zc rules a short chop must still
    honour. `grid` is accepted for signature parity with analyze_stem (chops are
    transient-driven, so it's unused) — keeps find_takes' call site uniform.
    """
    import librosa
    onsets = librosa.onset.onset_detect(y=y, sr=sr, units="time", backtrack=True)
    if len(onsets) < 2:
        return []
    cands: list[LoopCandidate] = []
    for i in range(len(onsets) - 1):
        for j in (i + 1, i + 2, i + 3):               # onset→next … skip-two (phrase-length)
            if j >= len(onsets):
                continue
            s, e = float(onsets[i]), float(onsets[j])
            if not (min_len_s <= e - s <= max_len_s):  # below min = a click, above = a loop
                continue
            s_smp, e_smp = int(s * sr), int(e * sr)
            sl = y[s_smp:e_smp]
            if sl.size < 16:
                continue
            seam, _ = G.seam_score(sl)
            zc, _, _ = G.zc_score(sl, sr)
            lvl, _, _, _ = G.level_score(sl)
            # transient strength at the loop-in onset → favour clean attacks
            score = 0.45 * seam + 0.2 * zc + 0.2 * lvl + 0.15
            cands.append(LoopCandidate(
                start_s=round(s, 3), end_s=round(e, 3), bars=0,   # bars=0 ⇒ a chop, not a bar-loop
                bpm=0.0, tempo_unstable=False, score=round(score, 4),
                structural=0.0, novelty=0.0, seam=round(seam, 4), zc=round(zc, 4)))
    return _dedup(cands, top_n)


def _dedup(cands: list[LoopCandidate], top_n: int) -> list[LoopCandidate]:
    """Greedy NMS: keep highest-scoring, drop windows overlapping >50%."""
    out: list[LoopCandidate] = []
    for c in sorted(cands, key=lambda x: -x.score):
        if all(_overlap(c, k) < 0.5 for k in out):
            out.append(c)
        if len(out) >= top_n:
            break
    return out


def _overlap(a: LoopCandidate, b: LoopCandidate) -> float:
    lo, hi = max(a.start_s, b.start_s), min(a.end_s, b.end_s)
    inter = max(0.0, hi - lo)
    return inter / max(1e-6, min(a.end_s - a.start_s, b.end_s - b.start_s))


# ── takes (cross-stem grouping) ───────────────────────────────────────────────
def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8, mode="loops",
               progress: Optional[Callable[[str], None]] = None) -> list[Take]:
    """Per-stem candidates → loosely grouped Takes (a shared time window).

    mode="loops" (default): bar-aligned 2/4/8-bar windows, one beat grid computed from
    the most rhythmic stem (drums) and reused for every stem so vocals/other are cut on
    the same bar lines instead of each stem's own weak PLP grid (the §7/#18 pt.1 fix).
    mode="chops": onset-driven sub-bar slices (#18 pt.2) — how PLN chops vocal/sample
    sources into stabs and word-chops; grid is unused there.
    """
    analyze = analyze_chops if mode == "chops" else analyze_stem
    catch_dir = catch.dir(workspace)
    loaded: dict[str, tuple] = {}
    for st in catch.stems:
        p = catch_dir / st.path
        if p.exists():
            loaded[st.name] = (G._mono(G.load_audio(p)[0]).astype(np.float32), _sr_of(p))

    # shared grid from the rhythmic source (drums → bass → first available); loops only
    grid = None
    if mode != "chops":
        for pick in ("drums", "bass", *loaded):
            if pick in loaded:
                if progress:
                    progress(f"beat grid from {pick}")
                grid = beat_grid(*loaded[pick])
                break

    per_stem: dict[str, list[LoopCandidate]] = {}
    for name, (y, sr) in loaded.items():
        if progress:
            progress(f"analyzing {name}")
        per_stem[name] = analyze(y, sr, top_n=top_n, grid=grid) if mode == "chops" \
            else analyze(y, sr, bars=bars, top_n=top_n, grid=grid)

    # cluster candidates from all stems by start time (within ~1 bar slack)
    flat = [(name, c) for name, cs in per_stem.items() for c in cs]
    flat.sort(key=lambda nc: nc[1].start_s)
    takes: list[Take] = []
    used = [False] * len(flat)
    for i, (name, c) in enumerate(flat):
        if used[i]:
            continue
        slack = c.bars * BEATS_PER_BAR * 60.0 / max(c.bpm, 1) / BEATS_PER_BAR  # ~1 bar
        members = [(j, flat[j]) for j in range(len(flat))
                   if not used[j] and abs(flat[j][1].start_s - c.start_s) <= slack]
        best_per_stem: dict[str, tuple[int, LoopCandidate]] = {}
        for j, (nm, cc) in members:
            if nm not in best_per_stem or cc.score > best_per_stem[nm][1].score:
                best_per_stem[nm] = (j, cc)
        for j, _ in best_per_stem.values():
            used[j] = True
        lead = max(best_per_stem.values(), key=lambda jc: jc[1].score)[1]
        stems = [StemSlice(name=nm, start_s=cc.start_s, end_s=cc.end_s, score=cc.score,
                           rms_dbfs=0.0, is_one_shot=(cc.end_s - cc.start_s) < 0.75)
                 for nm, (j, cc) in best_per_stem.items()]
        takes.append(Take(start_s=lead.start_s, end_s=lead.end_s, bars=lead.bars,
                          bpm=lead.bpm, tempo_unstable=lead.tempo_unstable,
                          score=round(lead.score, 4), n_stems=len(stems), stems=stems))
    takes.sort(key=lambda t: -t.score)
    return takes[:top_n]


def _sr_of(path: Path) -> int:
    return sf.info(str(path)).samplerate


# ── export (B-rules) ──────────────────────────────────────────────────────────
def _snap_zc(y: np.ndarray, idx: int, sr: int) -> int:
    """Snap a boundary to the nearest zero crossing within B1 tolerance."""
    tol = int(sr * ZC_TOL_MS / 1000)
    lo, hi = max(0, idx - tol), min(len(y) - 1, idx + tol)
    seg = y[lo:hi]
    if seg.size < 2:
        return idx
    zc = np.where(np.diff(np.signbit(seg)))[0]
    return int(lo + zc[np.argmin(np.abs(zc - (idx - lo)))]) if zc.size else idx


def bar_len_samples(bars: int, bpm: float, sr: int) -> int:
    """Exact sample length of `bars` bars at `bpm` (BEATS_PER_BAR beats/bar)."""
    if bars <= 0 or bpm <= 0:
        return 0
    return int(round(bars * BEATS_PER_BAR * (60.0 / bpm) * sr))


def _snap_length_preserving(y: np.ndarray, a: int, sr: int, length: int) -> tuple[int, int]:
    """Zero-crossing snap that PRESERVES the loop LENGTH exactly (task: bar-multiple guarantee).

    The naïve export snapped each edge to its own nearest zero crossing independently, so
    the two edges drifted apart by up to ±ZC_TOL_MS each and the exported DURATION deviated
    from an exact bar multiple (measured −4.2 ms on a 4-bar, −11.4 ms on 2-bar loops — both
    edges snapped inward). loopAt absorbs it, but the guarantee we want is
    |exported_dur − bars·bar_len| < 1 ms.

    Fix: keep the length RIGID at `length` (the exact bar-multiple sample count) and slide the
    WHOLE window by a single common offset so both edges land near zero simultaneously. The
    window [a+δ, a+δ+length) is evaluated for δ ∈ ±ZC_TOL_MS and we pick the δ minimizing the
    combined edge amplitude |y[start]| + |y[end]|. Because both edges move by the same δ, the
    length — hence the duration — is exactly preserved (start and end are that many samples
    apart by construction). Returns (start, end) with end - start == length (clamped to y).
    """
    n = len(y)
    if length <= 0 or n < 2:
        b = min(n, a + max(2, length))
        return max(0, min(a, n - 2)), b
    tol = int(sr * ZC_TOL_MS / 1000)
    # candidate common offsets, clamped so both edges stay inside the signal
    lo = max(-tol, -a)
    hi = min(tol, n - length - a)
    if hi < lo:                                   # window barely fits — no room to slide
        a = max(0, min(a, n - length))
        return a, a + length
    best_delta, best_cost = 0, float("inf")
    for delta in range(lo, hi + 1):
        s = a + delta
        e = s + length
        cost = abs(float(y[s])) + abs(float(y[min(e, n - 1)]))
        # prefer the smaller |δ| on ties so we don't wander from the intended bounds
        cost += 1e-9 * abs(delta)
        if cost < best_cost:
            best_cost, best_delta = cost, delta
    s = a + best_delta
    return s, s + length


def kit_multiples_check(files: list[Path], bpm: float, *, tol_pct: float = 0.5) -> dict:
    """Per-file duration-in-bars at the shared grid `bpm`; flag any off an integer multiple.

    Makes the bar-multiple guarantee EXPLICIT per forge (task 1b): after export, each written
    loop's duration is expressed in bars at the take's grid BPM and compared to the nearest
    integer bar count. A file deviating by more than `tol_pct` (%) is flagged — with a
    length-preserving snap this should never trip, so a flag means a real off-grid slice.
    Returns {"bpm", "bar_len_s", "files": [{name, dur_s, bars, nearest, dev_pct, ok}], "ok"}.
    """
    bar_len_s = (BEATS_PER_BAR * 60.0 / bpm) if bpm > 0 else 0.0
    rows: list[dict] = []
    all_ok = True
    for p in files:
        try:
            info = sf.info(str(p))
            dur = info.frames / info.samplerate
        except Exception:
            continue
        bars = dur / bar_len_s if bar_len_s > 0 else 0.0
        nearest = max(1, round(bars)) if bar_len_s > 0 else 0
        dev_pct = abs(bars - nearest) / nearest * 100.0 if nearest else 0.0
        ok = bar_len_s <= 0 or dev_pct <= tol_pct
        all_ok = all_ok and ok
        rows.append({"name": p.name, "dur_s": round(dur, 4), "bars": round(bars, 4),
                     "nearest": nearest, "dev_pct": round(dev_pct, 3), "ok": ok})
    return {"bpm": round(bpm, 3), "bar_len_s": round(bar_len_s, 6), "files": rows, "ok": all_ok}


def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
                keep=("drums", "bass", "other", "vocals"),
                names: Optional[dict[str, str]] = None, peak_norm=False,
                samples_root: Optional[Path] = None) -> list[Path]:
    """Write a Take's stem slices to Samples/<kit>/ honoring B1/B6/B8/B10, then link.

    B1 snap is LENGTH-PRESERVING (see `_snap_length_preserving`): the exact bar-multiple
    sample count is held rigid and the whole window slides by one common offset onto
    near-zero edges, so every exported loop's duration is a bar multiple to <1 ms rather
    than drifting inward per-edge. `export_take_report` wraps this with the kit-multiples
    check that surfaces the per-file deviation numbers.
    """
    from . import publish
    out_dir = (samples_root or publish.samples_root()) / kit
    out_dir.mkdir(parents=True, exist_ok=True)
    catch_dir = catch.dir(workspace)
    stem_path = {st.name: catch_dir / st.path for st in catch.stems}
    written: list[Path] = []
    for i, sl in enumerate(s for s in take.stems if s.name in keep):
        src = stem_path.get(sl.name)
        if not src or not src.exists():
            continue
        y, sr = sf.read(str(src), always_2d=True, dtype="float32")   # (n, ch)
        a = int(sl.start_s * sr)
        mono_ref = y[:, 0]
        # exact bar-multiple length from the take's grid; fall back to the raw span if the
        # slice has no bar count (chops: bars=0) so those keep the per-edge snap behaviour.
        length = bar_len_samples(take.bars, take.bpm, sr) if take.bars > 0 else 0
        if length > 0:
            a, b = _snap_length_preserving(mono_ref, a, sr, length)  # B1, length-rigid
        else:
            b = int(sl.end_s * sr)
            a, b = _snap_zc(mono_ref, a, sr), _snap_zc(mono_ref, b, sr)
        clip = y[a:b]
        if sl.name == "bass" and clip.shape[1] > 1:                   # B6 mono-sum
            clip = clip.mean(axis=1, keepdims=True)
        if peak_norm:                                                 # B10: off by default
            pk = np.max(np.abs(clip)) + 1e-9
            clip = clip * (10 ** (-1.0 / 20) / pk)
        from . import naming
        nm = (names or {}).get(sl.name) or naming.suggest_name(i, sl.name)  # NN_role (#13)
        dest = out_dir / f"{nm}.wav"
        sf.write(str(dest), clip, EXPORT_SR if sr == EXPORT_SR else sr,
                 subtype="PCM_24")                                    # B8
        written.append(dest)
    if written:
        try:
            publish.link_kit(kit)
        except Exception:
            pass
    return written


def export_take_report(take: Take, kit: str, workspace: Path, catch: Catch,
                       **kwargs) -> dict:
    """`export_take` + the kit-multiples check, returned together for the result/report path.

    Returns {"written": [Path…], "kit", "multiples": <kit_multiples_check dict>}. The
    server surfaces `multiples` so the bar-multiple guarantee is visible per forge; a
    non-ok row is a genuine off-grid warning.
    """
    written = export_take(take, kit, workspace, catch, **kwargs)
    return {"written": written, "kit": kit,
            "multiples": kit_multiples_check(written, take.bpm)}


# ── CLI ───────────────────────────────────────────────────────────────────────
def _main(argv=None) -> int:
    import argparse
    import json
    ap = argparse.ArgumentParser(description="rank loop candidates in a stem")
    ap.add_argument("stem")
    ap.add_argument("--bars", type=int, nargs="+", default=[2, 4, 8])
    ap.add_argument("--top", type=int, default=8)
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args(argv)
    y = G._mono(G.load_audio(a.stem)[0]).astype(np.float32)
    sr = _sr_of(Path(a.stem))
    cands = analyze_stem(y, sr, bars=tuple(a.bars), top_n=a.top)
    if a.json:
        print(json.dumps([c.model_dump() for c in cands], indent=2))
    else:
        for c in cands:
            print(f"  [{c.score:.3f}] {c.bars}-bar {c.start_s:6.1f}–{c.end_s:6.1f}s "
                  f"@{c.bpm:.0f}bpm  struct={c.structural:.2f} nov={c.novelty:.2f} "
                  f"seam={c.seam:.2f} zc={c.zc:.2f}{' ⚠tempo' if c.tempo_unstable else ''}")
    return 0


if __name__ == "__main__":
    raise SystemExit(_main())
