"""gemelo_gusto.py — EL GEMELO DE GUSTO (2026-07-17, catálogo `IDEAS_DJ_FRONTERA.md`: taste-twin LOCAL).
Herramienta 100% LOCAL: indexa TU biblioteca con una «huella acústica» por track (brillo, energía,
dinámica, ritmo, croma, bandas) y luego te dice **qué temas TUYOS suenan parecido a este** — sin subir
nada, sin datasets de la nube, sobre TU música.

DOS motores, y elige solo el que puede:
- **Motor ligero (SIEMPRE, cero deps nuevas):** numpy/scipy/soundfile. Similitud por características
  acústicas → «suena como este». No entiende texto, pero funciona hoy y en cualquier PC con Python.
- **Motor CLAP (si está instalado, en venv aparte):** embeddings audio-texto → además búsqueda por
  LENGUAJE NATURAL («algo así pero más oscuro y con menos voz»). Teed-up: CLAP es pesado (~GB).

EL SELLO: honesto. Es similitud de SONIDO medible, no «entiendo tu alma». Tu música no sale de tu equipo.
Reusa el motor del Duende (`duende_dj`) para cargar y estimar BPM.

Uso:
    python gemelo_gusto.py index "C:\\mi\\musica"          → construye la huella (gemelo_gusto_index.json)
    python gemelo_gusto.py like "C:\\mi\\musica\\tema.mp3"  → temas que suenan parecido
    python gemelo_gusto.py --sanity                         → valida el motor con señales sintéticas
"""
from __future__ import annotations
import sys, os, json, math, argparse
from pathlib import Path

import numpy as np
from scipy import signal as _sig

import duende_dj as _du       # reusa _cargar, bpm_de, EXTS, SR

SR = _du.SR
IDX_NOMBRE = "gemelo_gusto_index.json"


# ── Huella acústica (numpy/scipy) ───────────────────────────────────────────────────────────────
def _bandas_log(S: np.ndarray, f: np.ndarray, nb: int = 8) -> np.ndarray:
    """Energía media en nb bandas log-espaciadas (55 Hz .. 8 kHz)."""
    lo, hi = 55.0, 8000.0
    edges = np.logspace(math.log10(lo), math.log10(hi), nb + 1)
    out = np.zeros(nb)
    tot = S.sum(axis=1) + 1e-9
    for b in range(nb):
        m = (f >= edges[b]) & (f < edges[b + 1])
        out[b] = tot[m].sum()
    s = out.sum()
    return out / s if s > 0 else out


def huella(y: np.ndarray, sr: int) -> np.ndarray:
    """Vector de características acústicas del track (dim fija)."""
    f, t, Z = _sig.stft(y, fs=sr, nperseg=2048, noverlap=2048 - 512, boundary=None)
    S = np.abs(Z)
    Ssum = S.sum(axis=0) + 1e-9
    centroide = (f[:, None] * S).sum(axis=0) / Ssum                 # brillo por frame
    cen_m = float(centroide.mean()); cen_s = float(centroide.std())
    # ancho de banda espectral
    bw = np.sqrt(((f[:, None] - centroide[None, :]) ** 2 * S).sum(axis=0) / Ssum)
    bw_m = float(bw.mean())
    # rolloff 85%
    csum = np.cumsum(S, axis=0); thr = 0.85 * csum[-1]
    ro = np.array([f[np.searchsorted(csum[:, i], thr[i])] if thr[i] > 0 else 0.0 for i in range(S.shape[1])])
    ro_m = float(ro.mean())
    # zero-crossing rate
    zcr = float(np.mean(np.abs(np.diff(np.sign(y))) > 0))
    # dinámica: std de RMS por ventana
    win = int(sr * 0.1); nb = max(1, len(y) // win)
    rms = np.array([np.sqrt((y[i * win:(i + 1) * win] ** 2).mean() + 1e-12) for i in range(nb)])
    din = float(rms.std())
    # ritmo (reusa el Duende)
    bpm, _ = _du.bpm_de(y, sr); bpm = bpm or 0.0
    # croma (12) y bandas (8)
    ch = _du._chroma(y, sr)
    ba = _bandas_log(S, f)
    vec = np.concatenate([[cen_m, cen_s, bw_m, ro_m, zcr, din, bpm], ch, ba]).astype(np.float64)
    return vec


def _norm_stats(M: np.ndarray):
    mu = M.mean(axis=0); sd = M.std(axis=0); sd[sd < 1e-9] = 1.0
    return mu, sd


# ── Index + búsqueda ────────────────────────────────────────────────────────────────────────────
def indexar(carpeta: str, maxn: int = 500) -> dict:
    base = Path(carpeta)
    files = [str(p) for p in base.rglob("*") if p.suffix.lower() in _du.EXTS][:maxn]
    vecs, nombres = [], []
    for fp in files:
        car = _du._cargar(fp)
        if car is None:
            continue
        y, sr = car
        try:
            vecs.append(huella(y, sr).tolist()); nombres.append(os.path.basename(fp))
        except Exception:
            continue
    return {"carpeta": str(base), "n": len(nombres), "nombres": nombres, "vectores": vecs}


def parecidos(idx: dict, objetivo: str, n: int = 8) -> list[dict]:
    nombres = idx["nombres"]; M = np.array(idx["vectores"], dtype=np.float64)
    if M.shape[0] < 2:
        return []
    mu, sd = _norm_stats(M); Z = (M - mu) / sd
    # localizar objetivo por nombre (exacto o por inclusión)
    obj = os.path.basename(objetivo)
    if obj in nombres:
        i = nombres.index(obj)
    else:
        cand = [k for k, nm in enumerate(nombres) if obj.lower() in nm.lower()]
        if not cand:
            return []
        i = cand[0]
    q = Z[i]
    # distancia coseno (1 - cos) sobre features normalizadas
    def cos(a, b):
        d = np.linalg.norm(a) * np.linalg.norm(b)
        return float(a.dot(b) / d) if d > 0 else 0.0
    sims = [(cos(q, Z[j]), j) for j in range(len(nombres)) if j != i]
    sims.sort(reverse=True)
    return [{"archivo": nombres[j], "parecido": round(s, 3)} for s, j in sims[:n]]


# ── Sanity ──────────────────────────────────────────────────────────────────────────────────────
def sanity() -> bool:
    print("== SANITY del Gemelo de Gusto ==")
    def sint(fr_notas, ruidoso=False, d=8):
        t = np.arange(int(SR * d)) / SR
        y = np.zeros_like(t)
        for fr in fr_notas:
            y += 0.3 * np.sin(2 * np.pi * fr * t)
        if ruidoso:
            y += 0.4 * np.random.default_rng(1).standard_normal(len(t))
        return y.astype(np.float32)
    # A = grave/oscuro (notas bajas), B = casi igual, C = brillante/agudo (muy distinto)
    A = sint([110, 138.59, 164.81])           # acorde grave
    B = sint([110, 138.59, 164.81, 82.41])    # grave parecido
    C = sint([880, 1108.7, 1318.5])           # agudo, brillante
    hA, hB, hC = huella(A, SR), huella(B, SR), huella(C, SR)
    M = np.array([hA, hB, hC]); mu, sd = _norm_stats(M); Z = (M - mu) / sd
    def cos(a, b): return float(a.dot(b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
    sAB = cos(Z[0], Z[1]); sAC = cos(Z[0], Z[2])
    ok = sAB > sAC
    print(f"  parecido(A,B grave~grave)={sAB:.3f}  >  parecido(A,C grave~agudo)={sAC:.3f}  {'OK' if ok else 'FALLO'}")
    print("== RESULTADO:", "OK ✓" if ok else "FALLO ✗", "==")
    return ok


def _clap_disponible() -> bool:
    try:
        import importlib.util
        return importlib.util.find_spec("laion_clap") is not None
    except Exception:
        return False


# ── CAPA CLAP (opcional): búsqueda por TEXTO (audio-texto). Solo si laion_clap está instalado. ──
_CLAP = None
CLAP_IDX = "gemelo_clap_index.json"

def _clap_module():
    global _CLAP
    if _CLAP is None:
        import laion_clap
        m = laion_clap.CLAP_Module(enable_fusion=False)
        m.load_ckpt()
        _CLAP = m
    return _CLAP

def _emb_audio_clap(y: np.ndarray, sr: int) -> np.ndarray:
    y48 = _sig.resample_poly(y, 48000, sr).astype("float32") if sr != 48000 else y.astype("float32")
    emb = _clap_module().get_audio_embedding_from_data(x=np.stack([y48]), use_tensor=False)
    return np.asarray(emb[0], dtype=np.float64)

def indexar_clap(carpeta: str, maxn: int = 500) -> dict:
    base = Path(carpeta)
    files = [str(p) for p in base.rglob("*") if p.suffix.lower() in _du.EXTS][:maxn]
    vecs, nombres = [], []
    for k, fp in enumerate(files):
        car = _du._cargar(fp)
        if car is None:
            continue
        y, sr = car
        try:
            vecs.append(_emb_audio_clap(y, sr).tolist()); nombres.append(os.path.basename(fp))
            print(f"  [{k+1}/{len(files)}] {os.path.basename(fp)}", flush=True)
        except Exception:
            continue
    return {"carpeta": str(base), "n": len(nombres), "nombres": nombres, "vectores": vecs}

def buscar_texto(idx: dict, texto: str, n: int = 8) -> list[dict]:
    nombres = idx["nombres"]; M = np.array(idx["vectores"], dtype=np.float64)
    if M.shape[0] < 1:
        return []
    tq = np.asarray(_clap_module().get_text_embedding([texto], use_tensor=False)[0], dtype=np.float64)
    def cos(a, b):
        d = np.linalg.norm(a) * np.linalg.norm(b)
        return float(a.dot(b) / d) if d > 0 else 0.0
    sims = [(cos(tq, M[j]), j) for j in range(len(nombres))]
    sims.sort(reverse=True)
    return [{"archivo": nombres[j], "encaje": round(s, 3)} for s, j in sims[:n]]


def main():
    ap = argparse.ArgumentParser(description="El Gemelo de Gusto — taste-twin local y honesto.")
    ap.add_argument("cmd", nargs="?", choices=["index", "like", "find"],
                    help="index <carpeta> [--clap] | like <track> | find <carpeta> --texto '...'")
    ap.add_argument("ruta", nargs="?")
    ap.add_argument("--texto", default=None, help="descripción para 'find' (requiere CLAP)")
    ap.add_argument("--clap", action="store_true", help="usar CLAP (búsqueda por texto; motor pesado)")
    ap.add_argument("--n", type=int, default=8)
    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.cmd:
        sanity()
        print("CLAP (búsqueda por texto):", "disponible ✓" if _clap_disponible() else "no instalado (motor ligero activo)")
        if not args.cmd:
            return
    if args.cmd == "index":
        if args.clap:
            if not _clap_disponible():
                print("CLAP no está instalado en este Python. Índice ligero, o instala laion_clap.")
                return
            print("🧬 Indexando con CLAP (audio-texto, lento en CPU)…")
            idx = indexar_clap(args.ruta, args.max)
            out = Path(args.ruta) / CLAP_IDX
            out.write_text(json.dumps(idx, ensure_ascii=False), encoding="utf-8")
            print(f"🧬 Índice CLAP: {idx['n']} tracks → {out}")
        else:
            idx = indexar(args.ruta, args.max)
            out = Path(args.ruta) / IDX_NOMBRE
            out.write_text(json.dumps(idx, ensure_ascii=False), encoding="utf-8")
            print(f"🧬 Huella construida: {idx['n']} tracks → {out}")
    elif args.cmd == "find":
        if not _clap_disponible():
            print("La búsqueda por texto necesita CLAP (laion_clap). Sin él, usa 'like <track>'.")
            return
        if not args.texto:
            print("Dime qué buscas: --texto \"algo oscuro con poca voz\"")
            return
        idxf = Path(args.ruta) / CLAP_IDX
        if not idxf.exists():
            print(f"Primero: python gemelo_gusto.py index \"{args.ruta}\" --clap")
            return
        idx = json.loads(idxf.read_text(encoding="utf-8"))
        res = buscar_texto(idx, args.texto, args.n)
        print(f"🧬 Temas que encajan con «{args.texto}»:")
        for r in res:
            print(f"  · {r['archivo']}  (encaje {r['encaje']})")
    elif args.cmd == "like":
        carpeta = Path(args.ruta).parent
        idxf = carpeta / IDX_NOMBRE
        if not idxf.exists():
            print(f"Primero indexa la carpeta: python gemelo_gusto.py index \"{carpeta}\"")
            return
        idx = json.loads(idxf.read_text(encoding="utf-8"))
        res = parecidos(idx, args.ruta, args.n)
        if not res:
            print("No encontré ese track en el índice (o hay muy pocos). ¿Indexaste la carpeta?")
            return
        print(f"🧬 Temas que suenan como «{os.path.basename(args.ruta)}»:")
        for r in res:
            print(f"  · {r['archivo']}  (parecido {r['parecido']})")
        print("\nHonesto: es parecido de SONIDO (brillo/energía/ritmo/croma), no de 'vibe' semántico. "
              "La búsqueda por texto llega con CLAP (motor pesado, teed-up).")


if __name__ == "__main__":
    main()
