#!/usr/bin/env python3
"""
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.

Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
`docker exec -i` si FEA_PROD_DOCKER_CONTAINER está definido).

Uso:
  python3 sync_audio_to_prod.py --carta 54254            # sincroniza toda la cola de la carta
  python3 sync_audio_to_prod.py --ids 912,919,3001        # ids concretos
  python3 sync_audio_to_prod.py --carta 54254 --dry-run   # solo plan, no toca prod

Rollback (despublica en prod lo que este script publicó):
  python3 sync_audio_to_prod.py --rollback --carta 54254
  python3 sync_audio_to_prod.py --rollback --ids 912,919
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import subprocess
import time
from pathlib import Path

WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")

PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper y el mp3 se suben/ejecutan dentro del contenedor.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_post_io.php"
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")

HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"

LOG_FILE = Path(os.environ.get(
    "FEA_AUDIO_SYNC_LOG",
    str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
))
STATE_FILE = Path(os.environ.get(
    "FEA_AUDIO_SYNC_STATE",
    str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
))


def log(msg: str) -> None:
    line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
    print(line, flush=True)
    LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
    with LOG_FILE.open("a", encoding="utf-8") as f:
        f.write(line + "\n")


def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
    r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
    if r.returncode != 0:
        raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}…\n{r.stderr.decode(errors='replace')[:400]}")
    return r.stdout.decode(errors="replace")


# ── Local ────────────────────────────────────────────────────────────────────
def local_meta(post_id: int, key: str) -> str:
    r = subprocess.run(
        ["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
        capture_output=True, text=True, timeout=60,
    )
    if r.returncode != 0:
        raise RuntimeError(f"docker exec {WP_CONTAINER} falló leyendo meta '{key}' de #{post_id} (rc={r.returncode}): {r.stderr.strip()[:300]}")
    return r.stdout.strip()


def carta_article_ids(carta_id: int) -> list[int]:
    q = ("SELECT post_id FROM wp_postmeta "
         f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
    r = subprocess.run(
        ["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
         DB_NAME, "-N", "-e", q],
        capture_output=True, text=True, timeout=60,
    )
    if r.returncode != 0:
        raise RuntimeError(f"consulta de _carta_id={carta_id} falló (rc={r.returncode}): {r.stderr.strip()[:300]}")
    return [int(x) for x in r.stdout.split() if x.isdigit()]


# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
    # El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
    # solo se antepone si hay contraseña configurada (servidor viejo CDMON).
    if PROD_PASS:
        cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
               "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
    else:
        cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
               "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
    r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
    if r.returncode != 0:
        raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
    return r.stdout


def sh_quote(s: str) -> str:
    return "'" + s.replace("'", "'\\''") + "'"


def _remote_wrap(inner_cmd: str) -> str:
    """Envuelve un comando para que corra dentro del contenedor Docker de prod
    si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.

    El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
    shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
    resuelve esa redirección en el filesystem del HOST, no en el contenedor.
    """
    if PROD_DOCKER_CONTAINER:
        return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
    return inner_cmd


def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
    remote_cmd = _remote_wrap(f"cat > {remote_path}")
    if PROD_PASS:
        cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
               "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
    else:
        cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
               "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
    sh(cmd, input_bytes=data, timeout=timeout)


_prod_helper_ready = False


def prod_helper(subcmd: str, *args: str) -> str:
    global _prod_helper_ready
    if not _prod_helper_ready:
        _ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
        _prod_helper_ready = True
    inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
    return _ssh_text(_remote_wrap(inner), timeout=60)


def prod_upload_mp3(post_id: int) -> str:
    src = LOCAL_TTS_DIR / f"{post_id}.mp3"
    data = src.read_bytes()
    local_sha256 = hashlib.sha256(data).hexdigest()
    remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
    _ssh_upload_bytes(data, remote_path)
    # Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
    # sea el mismo. El hash se persiste como meta y permite playback verificado.
    remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
    if remote_size != len(data):
        raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
    remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
    if remote_sha256 != local_sha256:
        raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
    return local_sha256


def prod_remove_mp3(post_id: int) -> None:
    _ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))


# ── Estado ───────────────────────────────────────────────────────────────────
def load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text())
        except json.JSONDecodeError:
            pass
    return {"synced": []}


def save_state(state: dict) -> None:
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))


# ── Sync ─────────────────────────────────────────────────────────────────────
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
    if local_meta(post_id, "fea_audio_done") != "1":
        return "sin-audio-local"
    if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
        return "mp3-local-ausente"
    if dry_run:
        return "PLAN: subiría mp3 + setaudio"

    voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
    sha256 = prod_upload_mp3(post_id)
    prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
    prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
    if post_id not in state["synced"]:
        state["synced"].append(post_id)
        save_state(state)
    return "ok"


def rollback_one(post_id: int, state: dict) -> str:
    prod_helper("unsetaudio", str(post_id))
    prod_remove_mp3(post_id)
    if post_id in state["synced"]:
        state["synced"].remove(post_id)
        save_state(state)
    return "ok"


def main() -> int:
    ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
    ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
    ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
    ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
    ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
    args = ap.parse_args()

    if args.ids:
        ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
    elif args.carta:
        ids = carta_article_ids(args.carta)
    else:
        ap.error("hace falta --carta o --ids")
        return 2

    state = load_state()
    mode = "ROLLBACK" if args.rollback else "SYNC"
    log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")

    ok = skip = err = 0
    for pid in ids:
        try:
            if args.rollback:
                if pid not in state["synced"] and not args.ids:
                    res = "no-estaba-sincronizado"
                    skip += 1
                else:
                    res = rollback_one(pid, state)
                    ok += 1
            else:
                res = sync_one(pid, state, dry_run=args.dry_run)
                if res == "ok" or res.startswith("PLAN"):
                    ok += 1
                else:
                    skip += 1
            log(f"  #{pid}: {res}")
        except Exception as exc:  # noqa: BLE001
            err += 1
            log(f"  #{pid}: ERROR {exc}")

    log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
    return 1 if err else 0


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