#!/usr/bin/env python3
"""TTS con MiniMax (clonación de voz + síntesis de calidad). Issue #76.

Credenciales en /home/rafa/Feadulta/minimax.txt:
  - la API key (línea que empieza por 'sk-api-')
  - el GroupId (línea 'GroupId=...' o 'group_id ...' o un número suelto)

Subcomandos:
  clone <audio.wav> <voice_id>                  sube y clona (voice_id: >=8 chars, letras+números)
  carta <post_id> <voice_id> [model] [nombre]   locuta una carta entera
  text  "<texto>" <voice_id> [model] [nombre]   locuta texto suelto
models: speech-2.8-turbo (barato) | speech-2.8-hd (calidad)
"""
import html
import json
import os
import re
import subprocess
import sys
from pathlib import Path

import requests

CRED = "/home/rafa/Feadulta/minimax.txt"
BASE = "https://api.minimax.io/v1"
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
CONTAINER = "wordpress-web"


def creds():
    key = gid = None
    for ln in open(CRED):
        ln = ln.strip()
        if not ln:
            continue
        if ln.startswith("sk-"):
            key = ln  # coge la última key del fichero (la más reciente)
        elif "groupid" in ln.lower() or "group_id" in ln.lower():
            gid = re.split(r"[=:\s]+", ln, 1)[1].strip()
        elif ln.isdigit():
            gid = ln
    return key, gid


KEY, GID = creds()
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

# Voz clonada por autor (WP user_id -> voice_id). Issue #152: solo se aplica a
# los artículos de ese autor; el resto sigue con la voz por defecto (Nico).
AUTHOR_VOICES = {
    382: "FrayMarcosFeadulta2026",  # Fray Marcos
    383: "PagolaFeadulta2026",      # José Antonio Pagola
    774: "SicreFeadulta2026",       # José Luis Sicre
    386: "ArregiFeadulta2026",      # José Arregi
}


def voice_for_author(author_id, default_voice):
    try:
        return AUTHOR_VOICES.get(int(author_id), default_voice)
    except (TypeError, ValueError):
        return default_voice


def _q(url):
    return f"{url}?GroupId={GID}" if GID else url


def upload(path, purpose="voice_clone"):
    r = requests.post(_q(f"{BASE}/files/upload"),
                      headers={"Authorization": f"Bearer {KEY}"},
                      data={"purpose": purpose},
                      files={"file": open(path, "rb")})
    j = r.json()
    fid = (j.get("file") or {}).get("file_id")
    if not fid:
        sys.exit(f"upload falló: {json.dumps(j)[:400]}")
    print(f"  file_id={fid}")
    return fid


def clone(audio, voice_id):
    print(f"Subiendo {audio}…", flush=True)
    fid = upload(audio, "voice_clone")
    print(f"Clonando como voice_id={voice_id}…", flush=True)
    r = requests.post(_q(f"{BASE}/voice_clone"), headers=H_JSON,
                      json={"file_id": fid, "voice_id": voice_id, "model": "speech-2.8-hd"})
    print(json.dumps(r.json(), ensure_ascii=False)[:500])


def get_post_text(pid):
    subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
                   check=True, capture_output=True)
    subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
    d = json.load(open("/tmp/fea_es.json"))
    raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", d["content"])
    raw = re.sub(r"<[^>]+>", "", raw)
    raw = re.sub(r"\[[^\]]+\]", "", raw)
    raw = html.unescape(raw)
    paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
    paras = trim_after_author_signature(paras)
    return d["title"], "\n\n".join(paras), d.get("author")


def is_author_signature(text):
    """Heurística simple para detectar la firma final del autor.

    Queremos conservar la línea del nombre y cortar todo lo que venga detrás
    (URLs, notas, anexos o bloques extra), pero sin confundirla con títulos
    internos del artículo.
    """
    text = text.strip()
    if not text or len(text) > 80 or any(ch.isdigit() for ch in text):
        return False
    if any(mark in text for mark in [":", ";", "http", "www.", "@"]):
        return False
    words = text.split()
    if len(words) < 2 or len(words) > 6:
        return False
    allowed_lower = {"de", "del", "la", "las", "los", "y", "e"}
    for word in words:
        clean = re.sub(r"[^\wÁÉÍÓÚÜÑáéíóúüñ-]", "", word)
        if not clean:
            return False
        if clean.lower() in allowed_lower:
            continue
        if not clean[0].isupper():
            return False
    return True


def trim_after_author_signature(paras):
    """Corta tras la firma final del autor, buscando desde el final hacia
    atrás para no confundirla con encabezados iniciales (p.ej. "CORPUS (A)",
    "DOMINGO XI (A)") que también matchean la heurística de is_author_signature
    pero aparecen como primer párrafo, no como firma. Solo cuenta como firma
    si hay al menos 200 caracteres de contenido real antes de ella."""
    prefix_len = 0
    prefix_lens = []
    for p in paras:
        prefix_lens.append(prefix_len)
        prefix_len += len(p)
    for i in range(len(paras) - 1, -1, -1):
        if prefix_lens[i] > 200 and is_author_signature(paras[i]):
            return paras[: i + 1]
    return paras


def _sent_pause(n_words, short, long_):
    """Pausa (s) tras un punto, proporcional a la longitud de la frase que cierra:
    frase corta → pausa corta; frase larga → el narrador 'respira' más."""
    if n_words < short:
        return os.environ.get("FEA_PAUSE_SHORT", "0.1")
    if n_words <= long_:
        return os.environ.get("FEA_PAUSE_MID", "0.2")
    return os.environ.get("FEA_PAUSE_LONG", "0.3")


def ensure_terminal_punctuation(block):
    """Cierra con punto los bloques sin puntuación final.

    MiniMax deja la entonación abierta cuando un título/párrafo termina "en seco".
    Si el bloque ya acaba en . ! ? … : ;, se respeta.
    """
    block = block.strip()
    if not block:
        return ""
    if block[-1] not in ".!?…:;":
        return block + "."
    return block


def expand_bible_abbreviations(text):
    """Expande abreviaturas bíblicas cuando aparecen con forma de cita.

    Ejemplos:
    - Mt 5, 1-12   -> Mateo 5, 1-12
    - Lc 2, 10     -> Lucas 2, 10
    - Jn 3, 16     -> Juan 3, 16
    - Mc 1, 14     -> Marcos 1, 14

    Se limita a abreviaturas seguidas de capítulo/versículo para no tocar usos
    no bíblicos de esas siglas dentro del texto.
    """
    books = [
        ("1Cor", "Primera carta a los Corintios"),
        ("2Cor", "Segunda carta a los Corintios"),
        ("1Tes", "Primera carta a los Tesalonicenses"),
        ("2Tes", "Segunda carta a los Tesalonicenses"),
        ("1Tim", "Primera carta a Timoteo"),
        ("2Tim", "Segunda carta a Timoteo"),
        ("1Pe", "Primera carta de Pedro"),
        ("2Pe", "Segunda carta de Pedro"),
        ("1Jn", "Primera carta de Juan"),
        ("2Jn", "Segunda carta de Juan"),
        ("3Jn", "Tercera carta de Juan"),
        ("1Mac", "Primer libro de los Macabeos"),
        ("2Mac", "Segundo libro de los Macabeos"),
        ("1Sam", "Primer libro de Samuel"),
        ("2Sam", "Segundo libro de Samuel"),
        ("1Sm", "Primer libro de Samuel"),
        ("2Sm", "Segundo libro de Samuel"),
        ("1Re", "Primer libro de los Reyes"),
        ("2Re", "Segundo libro de los Reyes"),
        ("1Cr", "Primer libro de las Crónicas"),
        ("2Cr", "Segundo libro de las Crónicas"),
        ("Hch", "Hechos de los Apóstoles"),
        ("Rom", "Romanos"),
        ("Rm", "Romanos"),
        ("Gal", "Gálatas"),
        ("Gál", "Gálatas"),
        ("Ef", "Efesios"),
        ("Flp", "Filipenses"),
        ("Fil", "Filipenses"),
        ("Col", "Colosenses"),
        ("Tit", "Tito"),
        ("Flm", "Filemón"),
        ("Heb", "Hebreos"),
        ("Sant", "Santiago"),
        ("St", "Santiago"),
        ("Sto", "Santiago"),
        ("Jud", "Judas"),
        ("Ap", "Apocalipsis"),
        ("Mt", "Mateo"),
        ("Mc", "Marcos"),
        ("Lc", "Lucas"),
        ("Jn", "Juan"),
        ("Gn", "Génesis"),
        ("Gen", "Génesis"),
        ("Ex", "Éxodo"),
        ("Lv", "Levítico"),
        ("Lev", "Levítico"),
        ("Nm", "Números"),
        ("Num", "Números"),
        ("Dt", "Deuteronomio"),
        ("Jos", "Josué"),
        ("Jue", "Jueces"),
        ("Rut", "Rut"),
        ("Esd", "Esdras"),
        ("Neh", "Nehemías"),
        ("Tob", "Tobías"),
        ("Jdt", "Judit"),
        ("Est", "Ester"),
        ("Job", "Job"),
        ("Sal", "Salmos"),
        ("Prov", "Proverbios"),
        ("Cant", "Cantar de los Cantares"),
        ("Sab", "Sabiduría"),
        ("Eclo", "Eclesiástico"),
        ("Sir", "Eclesiástico"),
        ("Ecl", "Eclesiástico"),
        ("Isa", "Isaías"),
        ("Is", "Isaías"),
        ("Jer", "Jeremías"),
        ("Jr", "Jeremías"),
        ("Lam", "Lamentaciones"),
        ("Bar", "Baruc"),
        ("Eze", "Ezequiel"),
        ("Ez", "Ezequiel"),
        ("Dan", "Daniel"),
        ("Dn", "Daniel"),
        ("Os", "Oseas"),
        ("Joel", "Joel"),
        ("Am", "Amós"),
        ("Abd", "Abdías"),
        ("Jon", "Jonás"),
        ("Miq", "Miqueas"),
        ("Nah", "Nahúm"),
        ("Hab", "Habacuc"),
        ("Sof", "Sofonías"),
        ("Ag", "Ageo"),
        ("Zac", "Zacarías"),
        ("Mal", "Malaquías"),
    ]
    for short, full in books:
        text = re.sub(
            rf"\b{short}\.?(?=\s+\d)",
            full,
            text,
        )
    text = re.sub(r"\b1\s+Co\.?(?=\s+\d)", "Primera carta a los Corintios", text)
    text = re.sub(r"\b2\s+Co\.?(?=\s+\d)", "Segunda carta a los Corintios", text)
    text = re.sub(r"\b1\s+Ts\.?(?=\s+\d)", "Primera carta a los Tesalonicenses", text)
    text = re.sub(r"\b2\s+Ts\.?(?=\s+\d)", "Segunda carta a los Tesalonicenses", text)
    text = re.sub(r"\b1\s+P\.?(?=\s+\d)", "Primera carta de Pedro", text)
    text = re.sub(r"\b2\s+P\.?(?=\s+\d)", "Segunda carta de Pedro", text)
    return text


def add_pauses(text, para=None):
    """Pausas MiniMax <#seg#> DINÁMICAS por longitud de frase + cierre de párrafos.
    - Tras cada fin de frase (.!?…): pausa según nº de palabras de esa frase
      (<short=0.1s, <=long=0.2s, >long=0.3s; umbrales por palabras).
    - A los párrafos/títulos sin puntuación final se les añade un punto, para que
      MiniMax cierre bien la entonación (si no, deja el tono abierto)."""
    para = para if para is not None else os.environ.get("FEA_PARA_PAUSE", "0.7")
    short = int(os.environ.get("FEA_SHORT_WORDS", "6"))
    long_ = int(os.environ.get("FEA_LONG_WORDS", "12"))
    text = expand_bible_abbreviations(text)
    out = []
    for p in text.split("\n\n"):
        p = ensure_terminal_punctuation(p)
        if not p:
            continue
        # Reconstruir insertando pausa proporcional tras cada signo de fin de frase.
        parts = re.split(r"([.!?…]+)", p)
        rebuilt = ""
        for i in range(0, len(parts), 2):
            frase = parts[i]
            sign = parts[i + 1] if i + 1 < len(parts) else ""
            rebuilt += frase + sign
            if sign and frase.strip():
                rebuilt += f" <#{_sent_pause(len(frase.split()), short, long_)}#> "
        # Quitar la pausa de frase final: el separador de párrafo ya aporta la suya.
        rebuilt = re.sub(r"\s*<#[\d.]+#>\s*$", "", rebuilt)
        out.append(rebuilt.strip())
    return f" <#{para}#> ".join(out)


# MiniMax limita a 10.000 car por petición; dejamos margen porque las pausas
# <#seg#> y el language_boost también cuentan.
CHAR_LIMIT = 8000


def _split_for_tts(text, limit=CHAR_LIMIT):
    """Trocea respetando las pausas <#..#> (frase/párrafo). Fallback por palabras
    si una frase suelta supera el límite."""
    if len(text) <= limit:
        return [text]
    parts = re.split(r"(\s*<#[\d.]+#>\s*)", text)
    chunks, cur = [], ""
    for seg in parts:
        if not seg:
            continue
        if len(cur) + len(seg) <= limit:
            cur += seg
            continue
        if cur.strip():
            chunks.append(cur.strip())
        if len(seg) > limit:                       # frase gigantesca: parte por palabras
            cur = ""
            for w in seg.split(" "):
                if len(cur) + len(w) + 1 <= limit:
                    cur += (" " if cur else "") + w
                else:
                    if cur:
                        chunks.append(cur)
                    cur = w
        else:
            cur = seg
    if cur.strip():
        chunks.append(cur.strip())
    return chunks


def _synth_chunk(text, voice_id, model):
    """Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
    body = {
        "model": model,
        "text": text,
        "voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0},
        "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
        "language_boost": "Spanish",
    }
    r = requests.post(f"{BASE}/t2a_v2", headers=H_JSON, json=body)
    j = r.json()
    audio_hex = (j.get("data") or {}).get("audio")
    if not audio_hex:
        rc = (j.get("base_resp") or {}).get("status_code")
        print(f"t2a falló: {json.dumps(j, ensure_ascii=False)[:300]}")
        return None, rc, 0
    usage = (j.get("extra_info") or {}).get("usage_characters", 0)
    return bytes.fromhex(audio_hex), 0, usage


def t2a(text, voice_id, model, name):
    chunks = _split_for_tts(text)
    print(f"Sintetizando {len(text)} car con {model} / {voice_id} "
          f"({len(chunks)} petición/es)…", flush=True)
    raw = OUT / f"{name}.raw.mp3"
    if len(chunks) == 1:
        audio, rc, _ = _synth_chunk(chunks[0], voice_id, model)
        if audio is None:
            return rc
        raw.write_bytes(audio)
    else:
        parts = []
        for k, ch in enumerate(chunks):
            if k > 0:
                import os as _os, time as _t
                _t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35")))  # respetar TPM de MiniMax
            print(f"  trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
            audio, rc, _ = _synth_chunk(ch, voice_id, model)
            if audio is None:
                for p in parts:
                    p.unlink(missing_ok=True)
                return rc
            p = OUT / f"{name}.part{k}.mp3"
            p.write_bytes(audio)
            parts.append(p)
        import subprocess as sp0
        args = ["ffmpeg", "-y"]
        for p in parts:
            args += ["-i", str(p)]
        n = len(parts)
        filt = "".join(f"[{k}:a]" for k in range(n)) + f"concat=n={n}:v=0:a=1[a]"
        args += ["-filter_complex", filt, "-map", "[a]", "-b:a", "128k", str(raw)]
        sp0.run(args, capture_output=True)
        for p in parts:
            p.unlink(missing_ok=True)
    # Acabado: comfort noise marrón + fade in/out (quita el "bump" final).
    import subprocess as sp
    dur = float(sp.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                        "-of", "default=noprint_wrappers=1:nokey=1", str(raw)],
                       capture_output=True, text=True).stdout.strip() or "0")
    st = max(0.0, dur - 0.5)
    mp3 = OUT / f"{name}.mp3"
    sp.run(["ffmpeg", "-y", "-i", str(raw), "-filter_complex",
            "anoisesrc=color=brown:amplitude=0.004:sample_rate=32000[n];"
            "[n]highpass=f=120,lowpass=f=3800[nf];"
            "[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[m];"
            f"[m]afade=t=in:st=0:d=0.08,afade=t=out:st={st:.2f}:d=0.5[a]",
            "-map", "[a]", "-b:a", "128k", str(mp3)], capture_output=True)
    raw.unlink(missing_ok=True)
    print(f"OK -> {mp3}  ({dur:.0f}s)")
    return 0


def main():
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    cmd = sys.argv[1]
    if not KEY:
        sys.exit("No encuentro la API key en " + CRED)
    if cmd == "clone":
        clone(sys.argv[2], sys.argv[3])
    elif cmd == "carta":
        pid, voice_id = sys.argv[2], sys.argv[3]
        model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
        title, text, _author = get_post_text(int(pid))
        name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
        text = add_pauses(text)
        print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
        t2a(text, voice_id, model, name)
    elif cmd == "text":
        model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
        name = sys.argv[5] if len(sys.argv) > 5 else "minimax-text"
        t2a(sys.argv[2], sys.argv[3], model, name)
    else:
        sys.exit(__doc__)


if __name__ == "__main__":
    main()
