#!/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/


def check_grades(cuts: list[dict]) -> tuple[bool, list[str]]:
    """Re-grade the written files and compare with what the finder recorded."""
    from engine import grade as G
    lines, bad = [], []
    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 g.flags:
            if f in ("clipping", "near-silent", "mono-incompatible"):
                bad.append(f"  {c['kit']}/{c['name']}: {f}")
    lines.append("  tiers " + " ".join(f"{t}x{tiers[t]}" for t in "SABCD" if tiers[t]))
    lines += bad[:20]
    if len(bad) > 20:
        lines.append(f"  … and {len(bad) - 20} more")
    return not bad, lines


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()
        for kit in sorted({c["kit"] for c in cuts}):
            try:
                acted[P.link_kit(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())
