"""stempack — batch the Foundry over a folder of ALREADY-separated producer stems.

The Foundry's engine1 exists to *make* stems (yt-dlp → demucs). A producer stem
pack skips that entirely: the stems arrive labelled by the human who mixed them
(`MAREA MIX10 123BPM KIT STEM.wav`). This module is the missing batch driver
(TODO #20) for that case — walk a pack, cut every stem, grade, and land one
SuperDirt kit per track × role family.

    python3 -m engine.stempack "<pack dir>" --tracks MAREA ANGIE --prefix fred

Three things it does that `find_takes` could not:

1. **Resample to 44.1 kHz before analysis.** These packs ship at 96 kHz, and
   librosa's hop/window defaults are sample-rate-relative — at 96 k every analysis
   frame spans half the musical time and beat tracking wanders. Measured on
   `MAREA … 123BPM KIT`, whose filename is free ground truth: at 96 k the finder
   returned 123/123/124/**119**/**128.1**/**128.5** bpm with a 0.661 top score; at
   44.1 k it returned 123.0–123.1 on every candidate with a 0.859 top score, in a
   third of the time. Rate is a correctness input here, not a performance knob.

2. **Role names beyond the demucs four.** `find_takes` keys stems by
   drums/bass/other/vocals — a dict that collides on KEYS1+KEYS2 and an export
   `keep=` filter that would silently drop every stem in this pack. `roles.py`
   maps the pack's ~40 tokens to a family and then *verifies* the label by
   measurement before anything downstream trusts it.

3. **Loop-vs-chop by family, arbitrated by grade.** Composite rhythmic stems want
   bar loops; isolated hits and voices want chops; where the family is ambiguous
   both are run and `grade` picks. Every bar loop is exported at an exact bar
   multiple so `loopAt <bars>` is correct by construction.
"""
from __future__ import annotations

import json
import re
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Callable, Optional

import numpy as np
import soundfile as sf

from . import grade as G
from . import loops as L
from . import roles as R

ANALYSIS_SR = 44100          # see module docstring §1 — a correctness constant
MIN_TIER = "B"               # below this a loop does not ship
TIER_ORDER = ["D", "C", "B", "A", "S"]


@dataclass
class CutLoop:
    """One exported loop, with everything needed to play and to justify it."""
    kit: str
    name: str
    path: str
    track: str
    stem_role: str            # the producer's own token ("all drums")
    family: str               # our effective family, post-verification
    mode: str                 # "loop" | "chop"
    bars: int
    bpm: float
    dur_s: float
    start_s: float
    finder_score: float
    tier: str
    grade: float
    flags: list
    tags: dict                # CLAP multi-axis tags, filled in later (may be {})


def _load_44k(path: Path) -> tuple[np.ndarray, np.ndarray, int]:
    """Read a stem → (stereo (n,ch) @44.1k, mono (n,) @44.1k, source_sr).

    Everything downstream — the grid, the candidate times, the zero-crossing snap
    and the written audio — lives at one rate, so the file that ships is exactly
    the signal that was measured.
    """
    import librosa
    y, sr = sf.read(str(path), always_2d=True, dtype="float32")
    if sr != ANALYSIS_SR:
        # soxr_mq, not the soxr_hq default: ~3x faster and its passband is already
        # far cleaner than anything the downstream onset/MFCC features can resolve.
        y = librosa.resample(y.T, orig_sr=sr, target_sr=ANALYSIS_SR,
                             res_type="soxr_mq").T
    return np.ascontiguousarray(y), y.mean(axis=1), sr


def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True,
                  mono_sum: bool = False) -> tuple[np.ndarray, int]:
    """Cut a candidate out of the stereo stem honouring the B-rules.

    Reuses `loops`' own primitives rather than restating them: the length-preserving
    zero-crossing snap (so the duration stays an exact bar multiple), the downbeat
    rotation, and the DC removal. A chop (bars=0) gets per-edge snapping instead,
    since there is no bar length to preserve.

    Rotation is per-loop here, not per-shared-window as in `export_take`. That is the
    right call for kits split by family: a loop rotated to its own downbeat starts on
    its 1, so ANY two loops in the pack layer in phase — which is exactly what you
    want when `fred_marea_drums` and `fred_marea_bass` are cut from different moments
    of the track and still have to sit together under `loopAt`.
    """
    sr = ANALYSIS_SR
    mono_ref = y_st[:, 0]
    a = int(cand.start_s * sr)
    if cand.bars > 0:
        length = L.bar_len_samples(cand.bars, cand.bpm, sr)
        a, b = L._snap_length_preserving(mono_ref, a, sr, length)
    else:
        b = L._snap_zc(mono_ref, int(cand.end_s * sr), sr)
        a = L._snap_zc(mono_ref, a, sr)
    clip = y_st[a:b]
    if cand.bars > 0 and rotate:
        clip, _, _ = L._rotate_to_downbeat(clip, sr, cand.bars * L.BEATS_PER_BAR)
    if mono_sum and clip.shape[1] > 1:
        clip = clip.mean(axis=1, keepdims=True)
    return clip - clip.mean(axis=0, keepdims=True), sr


def _best_candidates(y_mono: np.ndarray, family: str, grid, *, per_stem: int,
                     bars: tuple) -> list:
    """Run the mode(s) this family calls for; when both, let the grade arbitrate.

    `analyze_stem`'s composite already blends structure/novelty/seam/zc, but it is
    not comparable across modes — a chop's score comes from a different formula.
    So when a family runs both, candidates are re-scored on the one axis that means
    the same thing for both (the true post-snap grade) before they compete.
    """
    mode = R.FAMILIES.get(family, R.FAMILIES["tonal"])["mode"]
    out = []
    if mode in ("loops", "both"):
        for c in L.analyze_stem(y_mono, ANALYSIS_SR, bars=bars, top_n=per_stem, grid=grid):
            out.append(("loop", c))
    if mode in ("chops", "both"):
        for c in L.analyze_chops(y_mono, ANALYSIS_SR, top_n=per_stem):
            out.append(("chop", c))
    return out


def _rank_by_grade(cands: list, y_st: np.ndarray, family: str,
                   keep: int) -> list[tuple[str, object, G.LoopGrade, np.ndarray]]:
    """Materialise each candidate exactly as it would be written, then grade THAT.

    This is the `verify` discipline from `analyze_stem` taken one step further: the
    finder verifies its seam on the post-snap window, and here the whole rubric is
    applied to the post-snap, post-rotation, DC-removed, mono-summed clip — the
    actual bytes. A candidate cannot score well on a signal that never ships.
    """
    mono_sum = family == "bass"
    scored = []
    for mode, c in cands:
        try:
            clip, sr = _slice_export(y_st, c, mono_sum=mono_sum)
        except Exception:
            continue
        if clip.shape[0] < sr // 8:                      # < 125 ms: not a sample
            continue
        g = G.grade_array(clip.T, sr, path=f"<{mode}@{c.start_s}>",
                          role=R.FAMILIES.get(family, {}).get("stem"))
        scored.append((mode, c, g, clip))
    # grade first, finder score as the tiebreak — two clips can both be mechanically
    # perfect, and then musical structure is the only thing left to separate them.
    scored.sort(key=lambda t: (-t[2].grade, -t[1].score))
    return scored[:keep]


def _pick_grid(stems: list[dict], progress) -> tuple[Optional[tuple], Optional[str]]:
    """One grid for the whole track, from its most rhythmic *verified* stem.

    `find_takes` picks by name (`drums` → `bass` → first). Here the pick is by
    measurement: the highest onset rate among stems that measured as drums, so a
    mislabelled or near-empty drum stem cannot set the grid every other stem is
    cut against.
    """
    rhythmic = [s for s in stems if s["probe"].measured_family == "drums"
                and s["probe"].usable]
    pool = rhythmic or [s for s in stems if s["probe"].usable]
    if not pool:
        return None, None
    lead = max(pool, key=lambda s: s["probe"].onset_rate)
    if progress:
        progress(f"  grid ← {lead['name'].role_token} "
                 f"(onset {lead['probe'].onset_rate:.1f}/s)")
    return L.beat_grid(lead["mono"], ANALYSIS_SR), lead["name"].role_token


def analyze_track(track_dir: Path, *, per_stem: int = 6, keep_per_stem: int = 3,
                  bars: tuple = (1, 2, 4, 8),
                  progress: Optional[Callable[[str], None]] = None) -> dict:
    """Load every stem of one track, verify it, and rank its best cuttable material."""
    wavs = sorted(p for p in track_dir.glob("*.wav"))
    stems = []
    for p in wavs:
        name = R.parse_stem_name(p.name)
        y_st, y_mono, src_sr = _load_44k(p)
        pr = R.probe(y_mono, ANALYSIS_SR, name.claimed_family)
        fam = R.effective_family(name, pr)
        if progress:
            mark = "·" if pr.agrees else "≠"
            skip = "" if pr.usable else "  SKIP"
            progress(f"  {mark} {name.role_token:18.18} {fam:6} "
                     f"rms {pr.rms_dbfs:6.1f}  act {pr.active_frac:4.0%}  "
                     f"cen {pr.centroid_hz:6.0f}Hz  on {pr.onset_rate:4.1f}/s"
                     f"{('  ' + pr.note) if pr.note else ''}{skip}")
        stems.append({"path": p, "name": name, "probe": pr, "family": fam,
                      "st": y_st, "mono": y_mono, "src_sr": src_sr})

    usable = [s for s in stems if s["probe"].usable]
    grid, grid_from = _pick_grid(usable, progress)
    grid_bpm = float(np.median(grid[2])) if grid is not None and len(grid[2]) else 0.0

    # the producer sometimes declares the tempo in the filename — free ground truth
    declared = next((s["name"].bpm_hint for s in stems if s["name"].bpm_hint), None)
    if declared and grid_bpm:
        err = abs(grid_bpm - declared) / declared * 100
        if progress:
            verdict = "✓" if err < 2 else "⚠"
            progress(f"  {verdict} grid {grid_bpm:.1f} bpm vs declared {declared:.0f} "
                     f"({err:.1f}% off)")

    for s in usable:
        if progress:
            progress(f"  cutting {s['name'].role_token}…")
        cands = _best_candidates(s["mono"], s["family"], grid,
                                 per_stem=per_stem, bars=bars)
        s["picks"] = _rank_by_grade(cands, s["st"], s["family"], keep_per_stem)

    return {"dir": track_dir, "stems": stems, "usable": usable,
            "grid_bpm": grid_bpm, "grid_from": grid_from, "declared_bpm": declared}


# ── export ───────────────────────────────────────────────────────────────────
def _tidal_token(s: str) -> str:
    """A sample-name token Tidal and the filesystem both like."""
    return re.sub(r"[^a-z0-9]+", "", s.lower())


def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = None,
                 min_tier: str = MIN_TIER, link: bool = True,
                 progress: Optional[Callable[[str], None]] = None) -> list[CutLoop]:
    """Write one kit per track × family and return what landed.

    Kit = `<prefix>_<track>_<family>`; file = `NN_<producer role>_<bars>b.wav`. The
    bar count is in the NAME because that is the number you need at the keyboard:
    `loopAt 4 $ s "fred_marea_drums" # n 0` is only right if that file really is
    four bars, and `kit_multiples_check` proves it after the fact.
    """
    from . import publish
    root = samples_root or publish.samples_root()
    floor = TIER_ORDER.index(min_tier)
    track_slug = _tidal_token(result["dir"].name)
    by_kit: dict[str, list] = {}
    for s in result["usable"]:
        for mode, cand, g, clip in s.get("picks", []):
            if TIER_ORDER.index(g.tier) < floor:
                continue
            by_kit.setdefault(f"{prefix}_{track_slug}_{s['family']}", []).append(
                (s, mode, cand, g, clip))

    out: list[CutLoop] = []
    for kit, items in sorted(by_kit.items()):
        items.sort(key=lambda t: (-t[3].grade,))
        kit_dir = root / kit
        kit_dir.mkdir(parents=True, exist_ok=True)
        written: list[Path] = []
        for i, (s, mode, cand, g, clip) in enumerate(items):
            role_tok = _tidal_token(s["name"].role_token) or "loop"
            suffix = f"{cand.bars}b" if cand.bars > 0 else "chop"
            dest = kit_dir / f"{i:02d}_{role_tok}_{suffix}.wav"
            sf.write(str(dest), clip, ANALYSIS_SR, subtype="PCM_24")
            written.append(dest)
            out.append(CutLoop(
                kit=kit, name=dest.stem, path=str(dest), track=result["dir"].name,
                stem_role=s["name"].role_token, family=s["family"], mode=mode,
                bars=cand.bars, bpm=round(cand.bpm or result["grid_bpm"], 2),
                dur_s=round(clip.shape[0] / ANALYSIS_SR, 4),
                start_s=cand.start_s, finder_score=cand.score,
                tier=g.tier, grade=g.grade, flags=list(g.flags), tags={}))
        chk = L.kit_multiples_check(written, result["grid_bpm"] or 120.0)
        if progress:
            bad = "" if chk.get("ok", True) else "  ⚠ off-grid rows"
            progress(f"  → {kit:34.34} {len(written):2} files{bad}")
        if link:
            try:
                publish.link_kit(kit, samples=root)
            except Exception as e:                        # never fail a cut on a link
                if progress:
                    progress(f"    (link skipped: {e})")
    return out


def tag_with_clap(cuts: list[CutLoop], *, progress=None) -> None:
    """Attach CLAP multi-axis tags in place. Optional — a failure never blocks a cut.

    The cheap probe in `roles.py` cannot tell a sung line from a rhodes chord; CLAP
    can, and it is what turns a kit listing into something searchable by vibe rather
    than by the producer's shorthand. It is advisory: `feedback_sample_identity` —
    CLAP proposes, PLN's ear confirms.
    """
    import sys
    tide = Path.home() / "Work/Sound/Tidal/armada/tide-table"
    if not tide.is_dir() or not cuts:
        return
    sys.path.insert(0, str(tide))
    try:
        import sample_semantics as SEM
    except Exception as e:
        if progress:
            progress(f"  (CLAP unavailable: {e})")
        return
    arrays = []
    for c in cuts:
        y, sr = sf.read(c.path, always_2d=True, dtype="float32")
        arrays.append(y.mean(axis=1))
    try:
        emb = np.asarray(SEM.embed_audio_arrays(arrays))
        for c, e in zip(cuts, emb):
            c.tags = SEM.tag_audio_embed(e, topk=2)
    except Exception as e:
        if progress:
            progress(f"  (CLAP tagging failed: {e})")


# ── report ───────────────────────────────────────────────────────────────────
def cheatsheet(cuts: list[CutLoop]) -> str:
    """A paste-ready `.tidal` block: every kit, every index, what it is, how to loop it.

    A kit you cannot address is a kit you will not use. Tidal indexes `n` by the
    sorted filename, which is exactly the `NN_` order written above — so the index
    printed here is the index that plays.
    """
    lines = ["-- fred kits — generated by engine.stempack; n indices are sort order", ""]
    for kit in sorted({c.kit for c in cuts}):
        items = sorted((c for c in cuts if c.kit == kit), key=lambda c: c.name)
        bpms = {round(c.bpm) for c in items if c.bpm}
        lines.append(f"-- {kit}  ({len(items)} samples"
                     + (f", {'/'.join(str(b) for b in sorted(bpms))} bpm)" if bpms else ")"))
        for i, c in enumerate(items):
            inst = (c.tags.get("instrument") or [("", 0)])[0][0]
            how = f"loopAt {c.bars}" if c.bars > 0 else f"one-shot {c.dur_s:.2f}s"
            lines.append(f"--   n {i:<2} {c.name:26.26} {c.tier}  {how:12.12} "
                         f"{c.stem_role}{('  · ' + inst) if inst else ''}")
        ex = items[0]
        if ex.bars > 0:
            lines.append(f'-- d1 $ loopAt {ex.bars} $ chop 8 $ s "{kit}" # n 0')
        else:
            lines.append(f'-- d1 $ s "{kit}*4" # n (irand {len(items)})')
        lines.append("")
    return "\n".join(lines)


def report_md(results: list[dict], cuts: list[CutLoop]) -> str:
    from collections import Counter
    lines = ["# Fred stem-pack cut — report", ""]
    for r in results:
        n_ok = len(r["usable"])
        skipped = [s["name"].role_token for s in r["stems"] if not s["probe"].usable]
        dis = [(s["name"].role_token, s["name"].claimed_family, s["probe"].measured_family)
               for s in r["stems"] if not s["probe"].agrees]
        lines += [f"## {r['dir'].name}", "",
                  f"- stems: {len(r['stems'])} ({n_ok} usable"
                  + (f", skipped near-silent: {', '.join(skipped)}" if skipped else "") + ")",
                  f"- grid: **{r['grid_bpm']:.1f} bpm** from `{r['grid_from']}`"
                  + (f" · producer declared **{r['declared_bpm']:.0f}**"
                     f" ({abs(r['grid_bpm'] - r['declared_bpm']) / r['declared_bpm'] * 100:.1f}% off)"
                     if r["declared_bpm"] else " · no declared tempo to check against")]
        if dis:
            lines.append("- label vs measurement disagreements (measurement wins):")
            lines += [f"  - `{t}` claimed *{c}*, measured **{m}**" for t, c, m in dis]
        mine = [c for c in cuts if c.track == r["dir"].name]
        tiers = Counter(c.tier for c in mine)
        lines += ["", f"- shipped {len(mine)} samples · tiers "
                  + " ".join(f"{t}×{tiers[t]}" for t in reversed(TIER_ORDER) if tiers[t]), ""]
        for kit in sorted({c.kit for c in mine}):
            ks = sorted((c for c in mine if c.kit == kit), key=lambda c: c.name)
            lines.append(f"### `{kit}` — {len(ks)}")
            lines.append("")
            lines.append("| n | file | tier | grade | bars | bpm | dur | from | CLAP |")
            lines.append("|--:|---|---|--:|--:|--:|--:|---|---|")
            for i, c in enumerate(ks):
                tg = "; ".join(f"{v[0][0]}" for k, v in c.tags.items()
                               if v and k in ("instrument", "timbre")) if c.tags else ""
                lines.append(
                    f"| {i} | `{c.name}` | {c.tier} | {c.grade:.3f} | "
                    f"{c.bars or '—'} | {c.bpm:.0f} | {c.dur_s:.2f}s | "
                    f"`{c.stem_role}` | {tg} |")
            lines.append("")
    return "\n".join(lines)


def _run_track(track_dir: Path, opts: dict) -> tuple[dict, list, str]:
    """Analyze + export one track. Runs in a worker process, so it returns text.

    Progress is buffered and returned rather than printed: interleaving nine tracks'
    per-stem lines across processes produces a log nobody can read. One block per
    track, printed when it lands.
    """
    buf: list[str] = [f"\u25b6 {track_dir.name}"]
    say = buf.append
    r = analyze_track(track_dir, per_stem=opts["per_stem"], keep_per_stem=opts["keep"],
                      bars=opts["bars"], progress=say)
    cuts: list[CutLoop] = []
    if not opts["dry_run"]:
        cuts = export_track(r, prefix=opts["prefix"], samples_root=opts["samples_root"],
                            min_tier=opts["min_tier"], link=opts["link"], progress=say)
    # drop the audio before this crosses a process boundary
    r["stems"] = [{k: v for k, v in s.items() if k not in ("st", "mono", "picks")}
                  for s in r["stems"]]
    r["usable"] = [s for s in r["stems"] if s["probe"].usable]
    return r, cuts, "\n".join(buf)


# ── CLI ──────────────────────────────────────────────────────────────────────
def main(argv=None) -> int:
    import argparse
    ap = argparse.ArgumentParser(prog="stempack", description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("pack", type=Path, help="folder of per-track stem folders")
    ap.add_argument("--tracks", nargs="*", default=None, help="track folders (default: all)")
    ap.add_argument("--prefix", default="fred", help="kit name prefix")
    ap.add_argument("--per-stem", type=int, default=6, help="candidates generated per stem")
    ap.add_argument("--keep", type=int, default=3, help="candidates kept per stem")
    ap.add_argument("--bars", type=int, nargs="+", default=[1, 2, 4, 8])
    ap.add_argument("--min-tier", default=MIN_TIER, choices=TIER_ORDER)
    ap.add_argument("--samples-root", type=Path, default=None)
    ap.add_argument("--out", type=Path, default=None, help="write report/cheatsheet here")
    ap.add_argument("--no-clap", action="store_true")
    ap.add_argument("--no-link", action="store_true")
    ap.add_argument("--dry-run", action="store_true", help="analyze and report, write nothing")
    ap.add_argument("-j", "--jobs", type=int, default=3,
                    help="tracks analyzed in parallel (each pins one core)")
    a = ap.parse_args(argv)

    names = a.tracks or sorted(d.name for d in a.pack.iterdir() if d.is_dir())
    todo = []
    for nm in names:
        d = a.pack / nm
        if d.is_dir():
            todo.append(d)
        else:
            print(f"! no such track folder: {d}", flush=True)

    opts = dict(per_stem=a.per_stem, keep=a.keep, bars=tuple(a.bars), prefix=a.prefix,
                samples_root=a.samples_root, min_tier=a.min_tier,
                link=not a.no_link, dry_run=a.dry_run)
    out = a.out or Path.cwd()
    out.mkdir(parents=True, exist_ok=True)

    results, cuts, frags = [], [], []
    if a.jobs == 1 or len(todo) == 1:
        for d in todo:
            r, c, log = _run_track(d, opts)
            print(log, flush=True)
            results.append(r); cuts += c
        frags = [report_md(results, cuts)]
    else:
        # A track is the unit of parallelism: every stem in a track is cut against one
        # shared beat grid, so stems cannot be split across workers, and tracks are
        # fully independent. Fan out as SUBPROCESSES rather than a ProcessPoolExecutor
        # — the pool's forkserver is broken under this Python (BrokenProcessPool on a
        # trivial task), and one process per track also means a track that blows up
        # loses only itself.
        cuts, frags = _fan_out(todo, a, out)

    if cuts and not a.no_clap:
        say = lambda m: print(m, flush=True)                                # noqa: E731
        say("\nCLAP tagging…")
        tag_with_clap(cuts, progress=say)      # once, in the parent: one model load

    (out / "fred_kits.md").write_text("\n".join(frags))
    (out / "fred_kits.tidal").write_text(cheatsheet(cuts))
    (out / "fred_kits.json").write_text(json.dumps([asdict(c) for c in cuts], indent=2))
    print(f"\n{len(cuts)} samples in {len({c.kit for c in cuts})} kits "
          f"\u2192 {out}/fred_kits.{{md,tidal,json}}", flush=True)
    return 0


def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]:
    """Run one `--jobs 1` subprocess per track, then merge their results.

    Children are told `--no-clap`: tagging loads a ~500 MB model, so it happens once
    in the parent over the merged set rather than once per track.
    """
    import subprocess
    from concurrent.futures import ThreadPoolExecutor
    scratch = out / "_tracks"
    scratch.mkdir(parents=True, exist_ok=True)

    def run(d: Path):
        sub = scratch / _tidal_token(d.name)
        cmd = [__import__("sys").executable, "-u", "-m", "engine.stempack", str(d.parent),
               "--tracks", d.name, "--jobs", "1", "--prefix", a.prefix,
               "--per-stem", str(a.per_stem), "--keep", str(a.keep),
               "--bars", *[str(b) for b in a.bars], "--min-tier", a.min_tier,
               "--out", str(sub), "--no-clap"]
        if a.samples_root:
            cmd += ["--samples-root", str(a.samples_root)]
        if a.no_link:
            cmd.append("--no-link")
        if a.dry_run:
            cmd.append("--dry-run")
        # tee to a per-track log rather than only capturing: a 30-minute analysis whose
        # output only appears at exit is a run you cannot supervise.
        sub.mkdir(parents=True, exist_ok=True)
        with open(sub / "run.log", "w") as lf:
            r = subprocess.run(cmd, cwd=Path(__file__).resolve().parent.parent,
                               stdout=lf, stderr=subprocess.STDOUT, text=True)
        return d, sub, r

    cuts, frags = [], []
    with ThreadPoolExecutor(max_workers=a.jobs) as ex:   # threads only wait on children
        for d, sub, r in ex.map(run, todo):
            log = (sub / "run.log").read_text() if (sub / "run.log").exists() else ""
            print(log.rstrip(), flush=True)
            if r.returncode != 0:
                print(f"! {d.name} failed (exit {r.returncode})", flush=True)
                continue
            j = sub / "fred_kits.json"
            if j.exists():
                cuts += [CutLoop(**c) for c in json.loads(j.read_text())]
            m = sub / "fred_kits.md"
            if m.exists():
                frags.append(m.read_text().replace("# Fred stem-pack cut — report\n", ""))
    return cuts, frags


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