"""duende_dj.py — EL DUENDE NOCTURNO (2026-07-17, catálogo `IDEAS_DJ_FRONTERA.md`, imán nº1: el agente
que prepara el bolo mientras el DJ duerme). Herramienta 100% LOCAL: apuntas a TU carpeta de música y de
madrugada te deja un «parte matinal del bolo» — BPM, tonalidad (Camelot), energía por track + crates
candidatos compatibles + duplicados sospechosos. EL SELLO = SE ABSTIENE: cuando la confianza de BPM o
tonalidad es baja, dice «no lo sé» en vez de inventar (filosofía `juez_universal`).

Honesto con el hardware: solo numpy/scipy/soundfile (NO librosa/Demucs/CLAP). No separa stems ni hace
vibe-embeddings (eso es teed-up con CLAP/16GB). Lo que SÍ hace es real y falsable, validado con señales
sintéticas (`--sanity`). Tu música NUNCA sale de tu equipo: lee ficheros locales y escribe un JSON local.

Uso:
    python duende_dj.py "C:\\ruta\\a\\mi\\musica"     → escanea y deja parte_matinal_dj.json + informe
    python duende_dj.py --sanity                      → prueba el motor con señales sintéticas
    python duende_dj.py "<carpeta>" --max 200         → limita nº de ficheros
"""
from __future__ import annotations
import sys, os, json, math, argparse, time
from pathlib import Path

import numpy as np
from scipy import signal as _sig

SR = 22050                      # sr de trabajo (resample) — suficiente para BPM/tonalidad
SEG_SEG = 90                    # analizar como mucho los primeros 90 s de cada track
EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aiff", ".aif"}

# ── Perfiles Krumhansl-Kessler (tonalidad) ──────────────────────────────────────────────────────
_KK_MAJ = np.array([6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88])
_KK_MIN = np.array([6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17])
_NOTAS = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
# Camelot: índice por nota (0=C..11=B)
_CAMELOT_MAJ = {0:"8B",1:"3B",2:"10B",3:"5B",4:"12B",5:"7B",6:"2B",7:"9B",8:"4B",9:"11B",10:"6B",11:"1B"}
_CAMELOT_MIN = {9:"8A",10:"3A",11:"10A",0:"5A",1:"12A",2:"7A",3:"2A",4:"9A",5:"4A",6:"11A",7:"6A",8:"1A"}


# ── Carga ───────────────────────────────────────────────────────────────────────────────────────
def _cargar(path: str) -> tuple[np.ndarray, int] | None:
    """Devuelve (mono float32 a SR, SR) o None si no se puede leer. Solo primeros SEG_SEG s."""
    import soundfile as sf
    try:
        info = sf.info(path)
        frames = min(info.frames, int(info.samplerate * SEG_SEG))
        y, sr = sf.read(path, frames=frames, dtype="float32", always_2d=True)
    except Exception:
        return None
    y = y.mean(axis=1)                                   # a mono
    if sr != SR and len(y) > 0:
        y = _sig.resample_poly(y, SR, sr)
    return y.astype(np.float32), SR


# ── Onset envelope (flujo espectral) ────────────────────────────────────────────────────────────
def _onset_env(y: np.ndarray, sr: int, hop: int = 512, nfft: int = 1024) -> tuple[np.ndarray, float]:
    f, t, Z = _sig.stft(y, fs=sr, nperseg=nfft, noverlap=nfft - hop, boundary=None)
    S = np.abs(Z)
    flux = np.diff(S, axis=1)
    flux[flux < 0] = 0
    env = flux.sum(axis=0)
    if env.size and env.max() > 0:
        env = env / env.max()
    fps = sr / hop
    return env, fps


# ── BPM por autocorrelación del onset envelope ──────────────────────────────────────────────────
def bpm_de(y: np.ndarray, sr: int) -> tuple[float | None, float]:
    env, fps = _onset_env(y, sr)
    if env.size < 8:
        return None, 0.0
    env = env - env.mean()
    ac = _sig.correlate(env, env, mode="full")
    ac = ac[ac.size // 2:]                               # lags >= 0
    if ac[0] <= 0:
        return None, 0.0
    lo = int(fps * 60 / 200)                             # 200 BPM
    hi = int(fps * 60 / 60)                              # 60 BPM
    hi = min(hi, ac.size - 1)
    if hi <= lo:
        return None, 0.0
    tramo = ac[lo:hi].copy()
    pico = int(np.argmax(tramo)) + lo
    bpm = 60.0 * fps / pico
    while bpm < 90:  bpm *= 2                            # normalizar a rango de baile
    while bpm > 180: bpm /= 2
    conf = float(ac[pico] / ac[0])                       # saliencia relativa al lag 0
    return round(bpm, 1), max(0.0, min(1.0, conf))


# ── Chroma + tonalidad (Krumhansl) ──────────────────────────────────────────────────────────────
def _chroma(y: np.ndarray, sr: int, hop: int = 2048, nfft: int = 8192) -> np.ndarray:
    f, t, Z = _sig.stft(y, fs=sr, nperseg=nfft, noverlap=nfft - hop, boundary=None)
    S = np.abs(Z)
    chroma = np.zeros(12)
    fbin = f
    for i, fr in enumerate(fbin):
        if fr < 55 or fr > 5000:
            continue
        pc = int(round(69 + 12 * math.log2(fr / 440.0))) % 12
        chroma[pc] += S[i].sum()
    if chroma.sum() > 0:
        chroma = chroma / chroma.sum()
    return chroma


def key_de(y: np.ndarray, sr: int) -> tuple[str | None, str | None, float]:
    """Devuelve (tonalidad legible, camelot, confianza). Abstención = (None, None, conf<umbral)."""
    ch = _chroma(y, sr)
    if ch.sum() <= 0:
        return None, None, 0.0
    def _corr(a, b):
        a = a - a.mean(); b = b - b.mean()
        d = np.sqrt((a * a).sum() * (b * b).sum())
        return float((a * b).sum() / d) if d > 0 else 0.0
    mejores = []
    for tonic in range(12):
        rot = np.roll(ch, -tonic)
        mejores.append((_corr(rot, _KK_MAJ), tonic, "maj"))
        mejores.append((_corr(rot, _KK_MIN), tonic, "min"))
    mejores.sort(reverse=True)
    corr, tonic, modo = mejores[0]
    segundo = mejores[1][0]
    conf = max(0.0, min(1.0, corr))
    margen = corr - segundo                              # cuánto gana al 2º candidato
    legible = _NOTAS[tonic] + ("m" if modo == "min" else "")
    camelot = (_CAMELOT_MIN if modo == "min" else _CAMELOT_MAJ)[tonic]
    # confianza combinada: buena correlación Y que gane claro al segundo
    conf_final = max(0.0, min(1.0, 0.6 * conf + 0.4 * min(1.0, margen * 6)))
    return legible, camelot, conf_final


def rms_energia(y: np.ndarray) -> float:
    if y.size == 0:
        return 0.0
    return float(np.sqrt((y.astype(np.float64) ** 2).mean()))


# ── Análisis de un track ────────────────────────────────────────────────────────────────────────
UMBRAL_BPM = 0.12      # por debajo → abstención (calibrado con sanity)
UMBRAL_KEY = 0.45

def analizar_track(path: str) -> dict:
    car = _cargar(path)
    nombre = os.path.basename(path)
    if car is None:
        return {"archivo": nombre, "legible": False, "motivo": "no pude leerlo (¿formato/corrupto?)"}
    y, sr = car
    dur = len(y) / sr
    bpm, bconf = bpm_de(y, sr)
    key, cam, kconf = key_de(y, sr)
    energia = rms_energia(y)
    # ABSTENCIÓN honesta
    if bpm is None or bconf < UMBRAL_BPM:
        bpm_out, bpm_nota = None, "no lo sé (poca certeza)"
    else:
        bpm_out, bpm_nota = bpm, None
    if key is None or kconf < UMBRAL_KEY:
        key_out, cam_out, key_nota = None, None, "no lo sé (poca certeza)"
    else:
        key_out, cam_out, key_nota = key, cam, None
    return {
        "archivo": nombre, "legible": True,
        "dur_s": round(dur, 1),
        "bpm": bpm_out, "bpm_conf": round(bconf, 2), "bpm_nota": bpm_nota,
        "tonalidad": key_out, "camelot": cam_out, "key_conf": round(kconf, 2), "key_nota": key_nota,
        "energia": round(energia, 4),
    }


# ── Compatibilidad Camelot ──────────────────────────────────────────────────────────────────────
def _camelot_vecinos(cam: str) -> set[str]:
    if not cam:
        return set()
    n = int(cam[:-1]); L = cam[-1]
    up = (n % 12) + 1; dn = 12 if n == 1 else n - 1
    otra = "A" if L == "B" else "B"
    return {cam, f"{up}{L}", f"{dn}{L}", f"{n}{otra}"}


# ── Escaneo de carpeta → parte matinal ──────────────────────────────────────────────────────────
def escanear(carpeta: str, maxn: int = 500) -> dict:
    base = Path(carpeta)
    files = [str(p) for p in base.rglob("*") if p.suffix.lower() in EXTS][:maxn]
    tracks = [analizar_track(f) for f in files]
    ok = [t for t in tracks if t.get("legible")]
    ilegibles = [t for t in tracks if not t.get("legible")]
    abst_bpm = [t for t in ok if t.get("bpm") is None]
    abst_key = [t for t in ok if t.get("camelot") is None]

    # crates candidatos: agrupar por BPM (±4) y Camelot compatible
    con_datos = [t for t in ok if t.get("bpm") and t.get("camelot")]
    crates = []
    usados = set()
    for i, t in enumerate(con_datos):
        if i in usados:
            continue
        grupo = [t]; usados.add(i)
        vec = _camelot_vecinos(t["camelot"])
        for j, u in enumerate(con_datos):
            if j in usados:
                continue
            if abs(u["bpm"] - t["bpm"]) <= 4 and u["camelot"] in vec:
                grupo.append(u); usados.add(j)
        if len(grupo) >= 2:
            crates.append({
                "centro_bpm": round(sum(g["bpm"] for g in grupo) / len(grupo), 1),
                "camelot_base": t["camelot"],
                "n": len(grupo),
                "tracks": [g["archivo"] for g in grupo][:20],
            })
    crates.sort(key=lambda c: -c["n"])

    # duplicados sospechosos: mismo BPM(±1) + misma tonalidad + duración parecida (±3s)
    dups = []
    for i in range(len(con_datos)):
        for j in range(i + 1, len(con_datos)):
            a, b = con_datos[i], con_datos[j]
            if (abs(a["bpm"] - b["bpm"]) <= 1 and a["camelot"] == b["camelot"]
                    and abs(a["dur_s"] - b["dur_s"]) <= 3):
                dups.append([a["archivo"], b["archivo"]])
    return {
        "generado": time.strftime("%Y-%m-%d %H:%M"),
        "carpeta": str(base),
        "n_total": len(files), "n_legibles": len(ok), "n_ilegibles": len(ilegibles),
        "abstenciones": {"bpm": len(abst_bpm), "tonalidad": len(abst_key)},
        "crates_candidatos": crates[:15],
        "duplicados_sospechosos": dups[:30],
        "tracks": ok,
        "ilegibles": [t["archivo"] for t in ilegibles],
    }


def _informe(res: dict) -> str:
    L = []
    L.append("🌙 PARTE MATINAL DEL BOLO — " + res["generado"])
    L.append(f"Carpeta: {res['carpeta']}")
    L.append(f"{res['n_legibles']} tracks leídos ({res['n_ilegibles']} no pude leerlos).")
    ab = res["abstenciones"]
    L.append(f"⚖️ Me abstengo en BPM de {ab['bpm']} y en tonalidad de {ab['tonalidad']} "
             f"(poca certeza → no invento).")
    L.append("")
    L.append(f"🎚️ {len(res['crates_candidatos'])} crates candidatos (BPM cercano + Camelot compatible):")
    for c in res["crates_candidatos"]:
        L.append(f"  · ~{c['centro_bpm']} BPM · base {c['camelot_base']} · {c['n']} tracks")
    if res["duplicados_sospechosos"]:
        L.append("")
        L.append(f"👀 {len(res['duplicados_sospechosos'])} posibles duplicados (mismo BPM+tono+duración) — míralos tú.")
    L.append("")
    L.append("Nota honesta: BPM/tonalidad/energía son reales y falsables; NO separo stems ni analizo "
             "'vibe' (eso pide CLAP/Demucs = más VRAM). Y donde no estoy segura, me callo.")
    return "\n".join(L)


# ── Sanity: validar el motor con señales sintéticas ─────────────────────────────────────────────
def sanity() -> bool:
    print("== SANITY del Duende (señales sintéticas) ==")
    ok = True
    # 1) click track a 120 BPM → bpm_de ~120
    dur = 20; t = np.arange(int(SR * dur)) / SR
    click = np.zeros_like(t)
    per = int(SR * 60 / 120)                             # 120 BPM
    click[::per] = 1.0
    click = _sig.lfilter([1], [1, -0.0], click)          # impulsos secos
    bpm, conf = bpm_de(click.astype(np.float32), SR)
    ok_bpm = bpm is not None and abs(bpm - 120) <= 3
    print(f"  BPM click 120 → {bpm} (conf {conf:.2f})  {'OK' if ok_bpm else 'FALLO'}")
    ok = ok and ok_bpm
    # 2) acorde de A menor (A-C-E) → key_de debería caer en Am o su relativa/vecina
    def tono(fr, d=10):
        tt = np.arange(int(SR * d)) / SR
        return 0.5 * np.sin(2 * np.pi * fr * tt)
    am = tono(220.0) + tono(261.63) + tono(329.63)        # A, C, E
    key, cam, kconf = key_de(am.astype(np.float32), SR)
    ok_key = key is not None and key[0] == "A"            # tónica A
    print(f"  Key A-C-E → {key} ({cam}, conf {kconf:.2f})  {'OK' if ok_key else 'FALLO'}")
    ok = ok and ok_key
    # 3) ruido blanco → BAJA confianza de tonalidad (debe poder abstenerse)
    rng = np.random.default_rng(0)
    ruido = rng.standard_normal(int(SR * 8)).astype(np.float32)
    _, _, kn = key_de(ruido, SR)
    ok_abst = kn < UMBRAL_KEY
    print(f"  Ruido → key_conf {kn:.2f} < {UMBRAL_KEY} (se abstendría)  {'OK' if ok_abst else 'FALLO'}")
    ok = ok and ok_abst
    print("== RESULTADO:", "TODO OK ✓" if ok else "HAY FALLOS ✗", "==")
    return ok


def main():
    ap = argparse.ArgumentParser(description="El Duende Nocturno — prep de bolo local y honesto.")
    ap.add_argument("carpeta", nargs="?", help="carpeta con tu música")
    ap.add_argument("--max", type=int, default=500)
    ap.add_argument("--sanity", action="store_true")
    args, _ = ap.parse_known_args()
    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass
    if args.sanity or not args.carpeta:
        sanity()
        if not args.carpeta:
            return
    res = escanear(args.carpeta, args.max)
    out = Path(args.carpeta) / "parte_matinal_dj.json"
    out.write_text(json.dumps(res, ensure_ascii=False, indent=2), encoding="utf-8")
    print(_informe(res))
    print(f"\n📄 Parte completo → {out}")


if __name__ == "__main__":
    main()
