#!/usr/bin/env python3
"""
sync_carta_from_prod.py — Copia una carta semanal (y su cluster de artículos)
de PROD a LOCAL preservando IDs, para poder traducir/locutar sobre un entorno
local alineado con prod.

Por qué existe: desde que Inma/Mixbot componen y publican la carta ES
directamente en WordPress prod (issue #165, #174), el WP local de Rafa deja
de recibir ese contenido — solo lo tenía porque antes todo pasaba por el
delta Joomla->local->prod. Sin este script, local queda desalineado (visto
con la carta 735 / ID 54495: ese ID en local correspondía a OTRO post) y
traducir/locutar ahí es peligroso (contenido cruzado). Ver
gitea.feadulta.com/rafa/feadulta#174.

Descubre el cluster parseando los enlaces internos de la propia carta
(fea_parse_carta_sections, la misma función que pinta la portada) en vez de
depender de _carta_id, que puede no estar puesto todavía.

Es el espejo de sync_translations_to_prod.py (modo IDs preservados): reutiliza
el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
(lee de prod por SSH, escribe en local por docker).

Uso:
    python3 sync_carta_from_prod.py --carta 54495 --dry-run
    python3 sync_carta_from_prod.py --carta 54495

Tras esto, el resto del ciclo ya existente no cambia:
    translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
    publish_carta.php
    rotate_cartas.php CARTA=54495 APPLY=1   (en PROD, por idioma ya publicado)
    tts_produce.py + sync_audio_to_prod.py
"""

from __future__ import annotations

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

WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")

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 se sube y se ejecuta dentro del contenedor.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_translate_helper.php"

HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"

# Por defecto NO se fuerza status: cada post se clona con el status que tiene
# en prod (publish/draft). Ver fea_translate_helper.php:clone (status vacío ->
# usa payload['status']).
STATUS = os.environ.get("FEA_SYNC_DOWN_STATUS", "")
LOG_FILE = Path(os.environ.get("FEA_SYNC_DOWN_LOG", "/tmp/feadulta-sync-down.log"))


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


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


# ── Prod (origen, solo lectura) ─────────────────────────────────────────────
_prod_ready = False


def _ssh(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]
    return sh(cmd, stdin=stdin, timeout=timeout)


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 prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
    global _prod_ready
    if not _prod_ready:
        _ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
        _prod_ready = True
    inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
    return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)


def prod_read_full(post_id: int) -> dict:
    return json.loads(prod_helper("read_full", str(post_id)))


def prod_carta_sections(carta_id: int) -> dict:
    raw = prod_helper("carta_sections", str(carta_id)).strip()
    return json.loads(raw) if raw else {}


def seed_ids_from_carta(carta_id: int) -> list[int]:
    sections = prod_carta_sections(carta_id)
    ids = {carta_id}
    for arr in sections.values():
        for pid in arr:
            ids.add(int(pid))
    return sorted(ids)


def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
    posts: dict[int, dict] = {}
    groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}

    for seed in seed_ids:
        info = prod_read_full(seed)
        posts[seed] = info
        raw_group = info.get("translations") or {}
        group = {lang: int(pid) for lang, pid in raw_group.items() if str(pid).isdigit()}
        if not group:
            lang = info.get("lang") or "es"
            group = {lang: seed}
        sig = tuple(sorted(group.items()))
        groups[sig] = group

    all_ids = sorted({pid for group in groups.values() for pid in group.values()})
    for pid in all_ids:
        if pid not in posts:
            posts[pid] = prod_read_full(pid)

    return posts, list(groups.values())


# ── Local (destino) ──────────────────────────────────────────────────────────
_local_ready = False


def local_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
    global _local_ready
    if not _local_ready:
        sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{LOCAL_HELPER_DST}"])
        _local_ready = True
    return sh(["docker", "exec", "-i", WP_CONTAINER, "php", LOCAL_HELPER_DST, subcmd, *args],
               stdin=stdin, timeout=180)


def local_read_safe(post_id: int) -> dict | None:
    try:
        return json.loads(local_helper("read", str(post_id)))
    except RuntimeError:
        return None


def local_clone(post: dict) -> int:
    payload = {
        "title": post["title"],
        "content": post.get("content", ""),
        "excerpt": post.get("excerpt", ""),
        "slug": post.get("slug", ""),
        "type": post.get("type", "post"),
        "author": post.get("author", 1),
        "date": post.get("date"),
        "date_gmt": post.get("date_gmt"),
        "status": post.get("status"),
        "cats": post.get("cats", []),
        "cat_slugs": post.get("cat_slugs", []),
        "meta": post.get("meta", {}),
    }
    out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
                        stdin=json.dumps(payload)).strip()
    return int(out)


def local_save_group(group: dict[str, int]) -> dict[str, int]:
    out = local_helper("save_translations", stdin=json.dumps({"translations": group})).strip()
    return json.loads(out)


# ── Main ─────────────────────────────────────────────────────────────────────
def sync_carta(carta_id: int, *, dry_run: bool) -> int:
    seed_ids = seed_ids_from_carta(carta_id)
    log(f"Cluster descubierto desde fea_parse_carta_sections(prod#{carta_id}): "
        f"{len(seed_ids)} post(s) -> {seed_ids}")
    if not seed_ids or seed_ids == [carta_id]:
        log("  ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
            "o secciones sin encabezados reconocibles?). Revisa antes de continuar.")

    posts, groups = collect_related_posts(seed_ids)

    conflicts = []
    for pid, p in posts.items():
        existing = local_read_safe(pid)
        if existing and existing.get("title") != p.get("title"):
            conflicts.append((pid, existing.get("title", ""), p.get("title", "")))

    if conflicts:
        log(f"  ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido "
            f"y va a ser SOBRESCRITO:")
        for pid, old_title, new_title in conflicts:
            log(f"      #{pid}: local actual «{old_title[:50]}»  ->  prod «{new_title[:50]}»")

    if dry_run:
        for pid, p in posts.items():
            log(f"  PULL prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
                f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
        for group in groups:
            log(f"  GROUP {group}")
        log("DRY-RUN: nada escrito en local.")
        return 0

    for pid, p in posts.items():
        new_id = local_clone(p)
        if new_id != pid:
            log(f"  ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.")
        else:
            log(f"  clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")

    for group in groups:
        if len(group) < 2:
            continue
        saved = local_save_group(group)
        log(f"  group enlazado en local {saved}")

    log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(
        description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
    ap.add_argument("--carta", type=int, required=True, help="ID del post ES de la carta en PROD.")
    ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
    args = ap.parse_args()

    if not PROD_HOST:
        raise SystemExit(
            "Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
            "el Hetzner nuevo usa auth por clave).\n"
            "Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
        )

    return sync_carta(args.carta, dry_run=args.dry_run)


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