#!/usr/bin/env python3
"""kitgate — the release gate for a freshly cut sample pack. Run before linking.

Cutting a pack produces audio; this decides whether it is fit to load into a running
rig. Five checks, four of them things a single earlier stage cannot answer alone:

1. **grade, on the shipped bytes.** `engine.grade` re-run over the written files, not
   the candidate windows the finder scored. It is a separate code path reaching the same
   verdict, so a disagreement means one of them is wrong — which is the point of running
   it twice (`feedback_assembling_checks_revalidates`).
2. **kitcheck.** Dead bars, bar-multiple exactness, per-family periodicity, duplicates.
3. **Bank shadowing.** `loadSoundFiles` defaults to appendToExisting = false, so a new
   folder whose basename matches an existing bank frees that bank's buffers and takes
   the name — silently, with the only symptom being that an old track now plays the
   wrong sound. Checked BEFORE anything is linked, via `tools/pvbanks.py` so this agrees
   with `bank-check.py` by construction (`feedback_one_parser_per_concept`).
4. **`n` indices.** The cheat sheet numbers `n` from the manifest; SuperDirt numbers it
   from the folder. Export never empties a kit dir, so a re-cut can leave a stale file
   behind and shift every index after it. Compared listing-to-listing, index for index.
5. **Rights.** A kit cut from someone else's stems must not be releasable per-track by
   default. Reported here, ledgered by `armada/tide-table/register_fred_rights.py`.

    python3 kitgate.py <cuts.json> [--link] [--source "..."]

`--link` performs the Dirt-Samples symlink for every kit, but only if 1-4 pass.\n`--prune` first deletes kit-dir files the manifest does not list.
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from collections import Counter
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
TIDAL_TOOLS = HERE.parent            # tools/


# A DEFECT makes the file unusable and blocks. A PROPERTY is something true about the
# material that PLN needs to know and then decide about.
#
# Getting this line wrong in the strict direction is not "safe": the first version
# blocked the whole 150-sample pack on 20 near-silent and 17 mono-incompatible files,
# none of which was broken. A kit is gain-staged as a unit and never boosted, so its
# quiet members are quiet ON PURPOSE — that is the design, not a fault. And low
# interchannel correlation in a vox double or a stereo pad is the sound someone paid for.
DEFECT_FLAGS = ("clipping", "empty")


def _blocking_flags(c: dict, g) -> list[str]:
    """The flags on this cut that mean it must not ship."""
    out = [f for f in g.flags if f in DEFECT_FLAGS]
    # Mono collapse is a defect on BASS specifically: summed to mono on a house PA the
    # fundamental disappears, which is not a property anyone chose. On a vox double or a
    # pad it is the width they paid for. ([[floor_response_vs_measurement]])
    if c.get("family") == "bass" and "mono-incompatible" in g.flags:
        out.append("mono-incompatible (bass — the fundamental dies in mono)")
    return out


def check_grades(cuts: list[dict]) -> tuple[bool, list[str]]:
    """Re-grade the written files and compare with what the finder recorded.

    Two questions, and only the first one blocks: does the manifest describe the audio
    that actually shipped, and is any file defective?
    """
    from engine import grade as G
    lines, bad, notes = [], [], []
    tiers = Counter()
    for c in cuts:
        g = G.grade(c["path"], role=c.get("family"))
        tiers[g.tier] += 1
        # the finder graded the same audio in memory; a real gap means one path is wrong
        if abs(g.grade - c["grade"]) > 0.02:
            bad.append(f"  ✗ {c['kit']}/{c['name']}: finder said {c['grade']:.3f}, "
                       f"file grades {g.grade:.3f}")
        for f in _blocking_flags(c, g):
            bad.append(f"  ✗ {c['kit']}/{c['name']}: {f}")
        for f in g.flags:
            if f in DEFECT_FLAGS or f == "one-shot":
                continue
            # `off-grid` on something the manifest calls a chop is noise: a sub-bar
            # one-shot has no bar grid to be off. grade guesses from duration; the
            # manifest KNOWS, so the manifest wins.
            if f == "off-grid" and not c.get("bars"):
                continue
            if f == "mono-incompatible" and c.get("family") == "bass":
                continue                       # already counted as blocking
            notes.append((f, c, g))
    lines.append("  tiers " + " ".join(f"{t}x{tiers[t]}" for t in "SABCD" if tiers[t])
                 + f" · {len(cuts)} files, manifest matches the audio"
                 + (f" · {len({id(c) for _, c, _ in notes})} worth a second listen"
                    if notes else ""))
    lines += bad[:20]
    if len(bad) > 20:
        lines.append(f"  … and {len(bad) - 20} more")
    lines += _advisory(notes)
    return not bad, lines


def _advisory(notes: list[tuple]) -> list[str]:
    """The properties, grouped, with the numbers that let PLN judge them by eye.

    Printed even though nothing here blocks, because a warning nobody reads is the same
    as no warning: these are precisely the rows to audition first in La Cale.
    """
    if not notes:
        return []
    by_flag: dict[str, list[tuple]] = {}
    for f, c, g in notes:
        by_flag.setdefault(f, []).append((c, g))
    out = []
    for f, items in sorted(by_flag.items(), key=lambda kv: -len(kv[1])):
        out.append(f"  · {f} ×{len(items)}  (advisory — a property, not a defect)")
        for c, g in items[:4]:
            m = g.metrics
            detail = (f"peak {m['peak_dbfs']:.1f} rms {m['rms_dbfs']:.1f}"
                      if f == "near-silent" else
                      f"corr {m['interchannel_corr']:+.2f}" if f == "mono-incompatible" else
                      f"seam {m['seam_click_db']:+.1f} dB")
            out.append(f"      {c['kit']}/{c['name']}  {g.tier}  {detail}")
        if len(items) > 4:
            out.append(f"      … and {len(items) - 4} more")
    return out


def check_kits(cuts: list[dict]) -> tuple[bool, list[str]]:
    from engine import kitcheck as K
    checks = K.check_cuts(cuts)
    hard = [c for c in checks if not c.ok]
    soft = [c for c in checks if c.ok and c.problems]
    dupes = [c for c in checks if c.dupe_of]
    lines = [f"  {len(checks) - len(hard) - len(soft)} clean, {len(soft)} advisory, "
             f"{len(hard)} failing · {len(checks) - len(dupes)} distinct sounds"]
    lines += [f"  ✗ {c.kit}/{c.name}: {'; '.join(c.problems)}" for c in hard[:20]]
    return not hard, lines


def check_shadowing(cuts: list[dict]) -> tuple[bool, list[str]]:
    """Would linking these kits steal a name an existing bank already answers to?"""
    sys.path.insert(0, str(TIDAL_TOOLS))
    try:
        from pvbanks import scan
    except Exception as e:
        return True, [f"  (skipped: {e})"]
    existing = scan()
    kits = sorted({c["kit"] for c in cuts})
    clash = [k for k in kits if k in existing]
    lines = [f"  {len(kits)} kits vs {len(existing)} known banks"]
    lines += [f"  ✗ {k} already exists: "
              f"{', '.join(str(e[0]) for e in existing[k])}" for k in clash]
    return not clash, lines


def check_indices(cuts: list[dict]) -> tuple[bool, list[str]]:
    """Does `n <i>` on disk address the file the cheat sheet says it does?

    The cheat sheet numbers `n` by enumerating the manifest sorted by name, and
    SuperDirt numbers it by enumerating the FOLDER sorted by name. Those two agree
    only as long as the folder holds exactly the manifest's files — and export writes
    into an existing kit dir without ever emptying it. So a re-cut that renames one
    file (`00_kick_2b.wav` → `00_kick_4b.wav`) leaves both behind, and every index
    from that point on addresses the wrong sound, silently, with a cheat sheet that
    looks right. Compare the two listings index for index, not as sets.

    Uses `pvbanks.playable` so "what SuperDirt will load" has one definition here,
    in `bank-check.py`, and in the watcher.
    """
    sys.path.insert(0, str(TIDAL_TOOLS))
    try:
        from pvbanks import playable
    except Exception as e:
        return True, [f"  (skipped: {e})"]
    by_kit: dict[str, list[dict]] = {}
    for c in cuts:
        by_kit.setdefault(c["kit"], []).append(c)
    bad, orphans = [], 0
    for kit, items in sorted(by_kit.items()):
        kit_dir = Path(items[0]["path"]).parent
        want = [c["name"] for c in sorted(items, key=lambda c: c["name"])]
        have = [p.stem for p in playable(kit_dir)]
        extra = [h for h in have if h not in want]
        orphans += len(extra)
        missing = [w for w in want if w not in have]
        shift = next((i for i, (w, h) in enumerate(zip(want, have)) if w != h), None)
        if extra or missing:
            bad.append(f"  ✗ {kit}: {len(extra)} orphan, {len(missing)} missing"
                       + (f" — n {shift} onward addresses the wrong file" if shift is not None
                          else " — indices still line up")
                       + (f" · orphans: {', '.join(extra[:4])}" if extra else ""))
    lines = [f"  {len(by_kit)} kit dirs hold exactly their {len(cuts)} manifest files"
             if not bad else f"  {orphans} orphan files across {len(bad)} kits"]
    lines += bad[:20]
    if orphans:
        lines.append("  → re-run with --prune to delete files the manifest does not list")
    return not bad, lines


def check_rights(cuts: list[dict], source: str) -> tuple[bool, list[str]]:
    """Are these banks recorded as NOT releasable per-track? Advisory, never blocking."""
    ledger = (TIDAL_TOOLS.parent / "armada/tide-table/rights_ledger.json")
    kits = sorted({c["kit"] for c in cuts})
    if not ledger.exists():
        return True, ["  (no ledger)"]
    banks = json.loads(ledger.read_text()).get("banks", {})
    unregistered = [k for k in kits if k not in banks]
    releasable = [k for k in kits
                  if banks.get(k, {}).get("status") in ("original", "cleared", "dirt_samples")]
    lines = [f"  {len(kits) - len(unregistered)} of {len(kits)} in the ledger"]
    if unregistered:
        lines.append(f"  ! {len(unregistered)} unregistered — run register_fred_rights.py "
                     f"so they block per-track release with their provenance attached")
    if releasable:
        lines.append(f"  ! {len(releasable)} marked releasable per-track: "
                     f"{', '.join(releasable[:5])}")
    return True, lines


def prune_orphans(cuts: list[dict]) -> int:
    """Delete kit-dir files the manifest does not list. Only ever inside a kit dir
    named by the manifest, and only ever an audio file — we never delete what we did
    not write."""
    sys.path.insert(0, str(TIDAL_TOOLS))
    from pvbanks import playable
    keep: dict[Path, set[str]] = {}
    for c in cuts:
        keep.setdefault(Path(c["path"]).parent, set()).add(c["name"])
    n = 0
    for kit_dir, names in keep.items():
        for f in playable(kit_dir):
            if f.stem not in names:
                f.unlink()
                n += 1
    return n


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(prog="kitgate", description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cuts_json", type=Path)
    ap.add_argument("--link", action="store_true", help="symlink into Dirt-Samples if clean")
    ap.add_argument("--source", default="third-party producer stem pack")
    ap.add_argument("--prune", action="store_true",
                    help="delete files in the kit dirs that the manifest does not list "
                         "(leftovers from an earlier cut) before checking")
    a = ap.parse_args(argv)

    cuts = json.loads(a.cuts_json.read_text())
    if a.prune:
        print(f"pruned {prune_orphans(cuts)} orphan files\n")
    print(f"kitgate — {len(cuts)} samples in {len({c['kit'] for c in cuts})} kits\n")

    results = {}
    for label, fn in [("grade (re-run on the written files)", lambda: check_grades(cuts)),
                      ("kitcheck", lambda: check_kits(cuts)),
                      ("bank shadowing", lambda: check_shadowing(cuts)),
                      ("n indices (folder vs manifest)", lambda: check_indices(cuts)),
                      ("rights", lambda: check_rights(cuts, a.source))]:
        ok, lines = fn()
        results[label] = ok
        print(f"{'✓' if ok else '✗'} {label}")
        for l in lines:
            print(l)
        print()

    blocking = [k for k, v in results.items() if not v and k != "rights"]
    if blocking:
        print(f"BLOCKED by: {', '.join(blocking)}")
        return 1
    print("all blocking checks pass")

    if a.link:
        from engine import publish as P
        acted = Counter()
        # Where the audio is comes from the MANIFEST, not from `publish.samples_root()`.
        # A pack cut with `--samples-root` is staged beside its source, so the default
        # root would look in the wrong place and report every kit as missing — and the
        # manifest is the one thing that already knows, per kit, exactly where its files
        # were written.
        roots = {c["kit"]: Path(c["path"]).parent.parent for c in cuts}
        for kit in sorted(roots):
            try:
                acted[P.link_kit(kit, samples=roots[kit]).action] += 1
            except Exception as e:
                print(f"  ! {kit}: {e}")
                acted["failed"] += 1
        print("linked into Dirt-Samples: " +
              ", ".join(f"{v} {k}" for k, v in sorted(acted.items())))
        if acted.get("skipped-real-dir"):
            print("  (a real directory was left untouched — we never delete what we did "
                  "not create)")
    return 0


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