#!/usr/bin/env python3
"""
Webhook consolidado para WhatsApp (Meta) + reenvío al endpoint interno.

Características:
- Verificación GET (hub.challenge) con VERIFY_TOKEN
- Validación de firma HMAC-SHA256 (X-Hub-Signature-256) usando APP_SECRET
- Respuesta rápida (200) y procesamiento en background (cola en memoria)
- Descarga de media (images/documents/audio) a disco y construcción de URL pública
- Reenvío del evento procesado a endpoint interno (CONFIG['WS_ENDPOINT']) con optional AUTH token
- Logging y rotación básica de memoria para evitar duplicados (idempotencia in-memory)
- Endpoints: GET /webhook, POST /webhook, POST /test_recibir, GET /status

Requisitos:
- pip install flask requests
- En producción: ejecutar detrás de nginx/traefik con TLS; o modificar para usar gunicorn/uvicorn.
"""

import os
import json
import time
import hmac
import hashlib
import logging
import threading
import queue
import requests
from datetime import datetime, timedelta
from pathlib import Path
from flask import Flask, request, jsonify, abort

# ---------------------------
# CONFIGURACIÓN (editar o usar variables de entorno)
# ---------------------------
CONFIG = {
    # Verification & security
    "VERIFY_TOKEN": os.getenv("WH_VERIFY_TOKEN", "It3g0_developer"),
    "APP_SECRET": os.getenv("WH_APP_SECRET", ""),  # App secret de Meta (recomendado)
    # Endpoint interno donde reenviar eventos (tu API /emitir)
    "WS_ENDPOINT": os.getenv("WS_ENDPOINT", "http://localhost:8081/emitir"),
    "WS_TIMEOUT": int(os.getenv("WS_TIMEOUT", "5")),
    # Token para autenticación entre servicios (opcional)
    "INTERNAL_API_TOKEN": os.getenv("INTERNAL_API_TOKEN", ""),
    # WhatsApp Graph token (para descargar media)
    "WHATSAPP_TOKEN": os.getenv("WH_WHATSAPP_TOKEN", ""),
    # Media urls y path
    "MEDIA_BASE_URL": os.getenv("MEDIA_BASE_URL", "https://oficina.itego.com.ar/media/"),
    "MEDIA_LOCAL_PATH": os.getenv("MEDIA_LOCAL_PATH", "./media/"),
    # Webhook server
    "WEBHOOK_HOST": os.getenv("WEBHOOK_HOST", "0.0.0.0"),
    "WEBHOOK_PORT": int(os.getenv("WEBHOOK_PORT", "5000")),
    "SSL_PORT": int(os.getenv("SSL_PORT", "5000")),
    "USE_SSL_IF_AVAILABLE": os.getenv("USE_SSL_IF_AVAILABLE", "1") == "1",
    # Debug/logging
    "DEBUG": os.getenv("WEBHOOK_DEBUG", "1") == "1",
    # Duplicate window (seconds) to keep seen message ids
    "DUPLICATE_TTL_SECONDS": int(os.getenv("DUPLICATE_TTL_SECONDS", str(60 * 60 * 24))),  # 24h
    # Background queue size & retry
    "QUEUE_MAXSIZE": int(os.getenv("QUEUE_MAXSIZE", "1000")),
    "MAX_RETRIES": int(os.getenv("MAX_RETRIES", "3")),
}

# ---------------------------
# Setup logging & folders
# ---------------------------
if CONFIG["DEBUG"]:
    logging.basicConfig(level=logging.DEBUG,
                        format="%(asctime)s [%(levelname)s] %(message)s")
else:
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s [%(levelname)s] %(message)s")

Path(CONFIG["MEDIA_LOCAL_PATH"]).mkdir(parents=True, exist_ok=True)
Path("logs").mkdir(parents=True, exist_ok=True)

# ---------------------------
# Aplicación Flask
# ---------------------------
app = Flask(__name__)

# ---------------------------
# Cola y estructuras para procesamiento en background
# ---------------------------
event_queue = queue.Queue(maxsize=CONFIG["QUEUE_MAXSIZE"])

# Idempotencia básica en memoria: dict message_id -> timestamp (epoch)
# Nota: en producción se recomienda Redis/DB persistente para idempotencia entre procesos.
seen_messages = {}
seen_lock = threading.Lock()

def prune_seen():
    """Eliminar entradas antiguas en seen_messages (llamado periódicamente)."""
    now = time.time()
    cutoff = now - CONFIG["DUPLICATE_TTL_SECONDS"]
    with seen_lock:
        keys = list(seen_messages.keys())
        for k in keys:
            if seen_messages.get(k, 0) < cutoff:
                del seen_messages[k]

# Worker background
def worker_loop():
    logging.info("🧰 Worker iniciado (procesamiento de eventos en background).")
    while True:
        try:
            item = event_queue.get()
            if item is None:
                logging.info("🧰 Worker recibiendo señal de parada.")
                break

            payload, retries = item.get("payload"), item.get("retries", 0)
            process_event(payload, retries)
            event_queue.task_done()
        except Exception as e:
            logging.exception(f"Worker fallo inesperado: {e}")

# ---------------------------
# UTILIDADES
# ---------------------------
def verify_signature(request_body: bytes, header_value: str) -> bool:
    """
    Valida X-Hub-Signature-256 con APP_SECRET.
    header_value esperado: "sha256=HEX"
    """
    if not CONFIG["APP_SECRET"]:
        logging.debug("🔒 APP_SECRET no configurado - saltando verificación de firma (no recomendado).")
        return True

    if not header_value:
        logging.warning("🔒 Firma ausente en cabecera.")
        return False

    try:
        alg, signature = header_value.split("=", 1)
    except Exception:
        logging.warning("🔒 Formato de firma inválido.")
        return False

    if alg.lower() != "sha256":
        logging.warning("🔒 Algoritmo de firma no soportado: %s", alg)
        return False

    expected = hmac.new(CONFIG["APP_SECRET"].encode("utf-8"), request_body, hashlib.sha256).hexdigest()
    verified = hmac.compare_digest(expected, signature)
    if not verified:
        logging.warning("🔒 Firma HMAC no coincide.")
    return verified

def is_valid_whatsapp_payload(data: dict) -> bool:
    try:
        return (
            data.get("object") == "whatsapp_business_account"
            and isinstance(data.get("entry"), list)
            and len(data["entry"]) > 0
            and "changes" in data["entry"][0]
        )
    except Exception:
        return False

def normalize_phone(phone: str) -> str:
    import re
    if not phone:
        return ""
    return re.sub(r'[^0-9]', '', phone)

def download_media(media_id: str, filename: str) -> str:
    """
    Descarga media desde WhatsApp Graph API y guarda localmente.
    Devuelve la URL pública o None si falla.
    """
    try:
        if not CONFIG["WHATSAPP_TOKEN"]:
            logging.error("❌ WHATSAPP_TOKEN no configurado, no se puede descargar media.")
            return None

        headers = {"Authorization": f"Bearer {CONFIG['WHATSAPP_TOKEN']}"}
        # 1) obtener info del media para la URL temporal
        meta_url = f"https://graph.facebook.com/v17.0/{media_id}"
        r = requests.get(meta_url, headers=headers, timeout=6)
        if r.status_code != 200:
            logging.error("❌ Falló obtener metadata media %s - %s", media_id, r.text[:200])
            return None
        meta = r.json()
        url = meta.get("url")
        if not url:
            logging.error("❌ No se obtuvo URL temporal para media_id %s", media_id)
            return None

        # 2) descargar contenido
        r2 = requests.get(url, headers=headers, timeout=12)
        if r2.status_code != 200:
            logging.error("❌ Falló descargar media %s - %s", media_id, r2.text[:200])
            return None

        # 3) guardar local
        file_path = Path(CONFIG["MEDIA_LOCAL_PATH"]) / filename
        file_path.write_bytes(r2.content)
        logging.info("💾 Media guardado: %s", file_path)

        # 4) devolver URL pública
        return CONFIG["MEDIA_BASE_URL"].rstrip("/") + "/" + filename
    except Exception as e:
        logging.exception("❌ Excepción al descargar media: %s", e)
        return None

def build_message_payload(msg: dict, contact: dict, value: dict):
    """
    Construye el payload que se enviará al endpoint interno (WS_ENDPOINT).
    msg: el mensaje específico (messages[0])
    contact: contacts[0]
    value: value (contiene metadata, etc.)
    """
    try:
        from_phone = normalize_phone(msg.get("from"))
        msg_type = msg.get("type", "unknown")
        ts = msg.get("timestamp") or int(time.time())
        base = {
            "from": from_phone,
            "timestamp": ts,
            "type": msg_type,
            "direction": "in",
            "metadata": {
                "whatsapp": {
                    "message_id": msg.get("id"),
                    "provider_timestamp": msg.get("timestamp"),
                    "phone_number_id": value.get("metadata", {}).get("phone_number_id")
                }
            }
        }

        if msg_type == "text":
            base.update({
                "body": msg.get("text", {}).get("body", "")
            })
            return base

        if msg_type == "image":
            media_id = msg.get("image", {}).get("id")
            filename = f"img_{media_id}.jpg"
            url_public = download_media(media_id, filename) if media_id else None
            base.update({
                "body": msg.get("image", {}).get("caption") or filename,
                "media": {
                    "id": media_id,
                    "mime_type": msg.get("image", {}).get("mime_type", "image/jpeg"),
                    "caption": msg.get("image", {}).get("caption"),
                    "url": url_public
                }
            })
            return base

        if msg_type == "document":
            media_id = msg.get("document", {}).get("id")
            filename_raw = msg.get("document", {}).get("filename", "document")
            ext = Path(filename_raw).suffix or ""
            filename = f"doc_{media_id}{ext}"
            url_public = download_media(media_id, filename) if media_id else None
            base.update({
                "body": filename_raw,
                "media": {
                    "id": media_id,
                    "filename": filename_raw,
                    "mime_type": msg.get("document", {}).get("mime_type", "application/octet-stream"),
                    "url": url_public
                }
            })
            return base

        if msg_type == "audio":
            media_id = msg.get("audio", {}).get("id")
            filename = f"aud_{media_id}.ogg"
            url_public = download_media(media_id, filename) if media_id else None
            base.update({
                "body": "[audio]",
                "media": {
                    "id": media_id,
                    "mime_type": msg.get("audio", {}).get("mime_type", "audio/ogg"),
                    "url": url_public,
                    "duration": msg.get("audio", {}).get("duration")
                }
            })
            return base

        # FallBack
        base.update({
            "body": f"[unsupported type: {msg_type}]",
            "type": "unsupported"
        })
        return base
    except Exception as e:
        logging.exception("❌ Error construyendo payload: %s", e)
        return {
            "from": msg.get("from"),
            "timestamp": msg.get("timestamp", int(time.time())),
            "type": "error",
            "body": ""
        }

def forward_to_internal(payload: dict):
    """
    Envía el payload procesado al endpoint interno (WS_ENDPOINT).
    Retorna diccionario con resultado.
    """
    headers = {"Content-Type": "application/json"}
    if CONFIG["INTERNAL_API_TOKEN"]:
        headers["Authorization"] = f"Bearer {CONFIG['INTERNAL_API_TOKEN']}"

    try:
        r = requests.post(CONFIG["WS_ENDPOINT"], json=payload, headers=headers, timeout=CONFIG["WS_TIMEOUT"])
        success = r.status_code == 200
        logging.info("🔁 Forward a interno %s -> %s (status=%s)", payload.get("from"), CONFIG["WS_ENDPOINT"], r.status_code)
        return {"success": success, "status": r.status_code, "text": r.text}
    except requests.exceptions.RequestException as e:
        logging.error("❌ Error forwarding to internal: %s", e)
        return {"success": False, "error": str(e)}

# ---------------------------
# Procesamiento del evento (consumer)
# ---------------------------
def process_event(raw_payload: dict, retries: int = 0):
    """
    Procesa el evento recibido:
    - verifica idempotencia usando message id si está disponible
    - construye payload y lo reenvía al endpoint interno
    - en caso de fallo, reintenta hasta MAX_RETRIES (con backoff) reinserción en cola
    """
    try:
        # Estructura: entry[0].changes[0].value
        entry = raw_payload.get("entry", [])
        if not entry:
            logging.warning("Payload sin entry, ignorando.")
            return

        change = entry[0].get("changes", [])[0]
        value = change.get("value", {})
        messages = value.get("messages", [])
        contacts = value.get("contacts", [])

        if not messages:
            logging.debug("Evento sin mensajes (statuses/otros) recibido, guardando raw y devolviendo.")
            # Podés procesar statuses aquí si hace falta
            return

        msg = messages[0]
        contact = contacts[0] if contacts else {}

        message_id = msg.get("id")
        now_ts = time.time()

        if message_id:
            with seen_lock:
                if message_id in seen_messages:
                    logging.info("⚠️ Mensaje %s ya procesado previamente, ignorando (idempotencia).", message_id)
                    return
                seen_messages[message_id] = now_ts

        # Prune occasionally
        if int(now_ts) % 100 == 0:
            prune_seen()

        # Build payload
        payload = build_message_payload(msg, contact, value)
        logging.debug("Payload construido: %s", json.dumps(payload, ensure_ascii=False)[:1000])

        # Forward to internal
        result = forward_to_internal(payload)
        if not result.get("success") and retries < CONFIG["MAX_RETRIES"]:
            backoff = 2 ** retries
            logging.warning("Reintentando en %s s (retry %s) para mensaje %s", backoff, retries + 1, message_id)
            # requeue with increased retry count
            time.sleep(backoff)  # small backoff here; could instead schedule delayed requeue
            try:
                event_queue.put_nowait({"payload": raw_payload, "retries": retries + 1})
            except queue.Full:
                logging.error("Cola llena: no se pudo reenqueue el payload.")
        elif not result.get("success"):
            logging.error("Fallo persistente al reenviar, se descarta después de retries.")
            # Optionally: guardar en archivo de errores para reprocesar
            with open("logs/failed_forwards.log", "a") as fh:
                fh.write(f"{datetime.utcnow().isoformat()} FAILED_FORWARD message_id={message_id} result={result}\n")

    except Exception as e:
        logging.exception("❌ Excepción procesando evento: %s", e)

# ---------------------------
# RUTAS FLASK
# ---------------------------
@app.route("/", methods=["GET"])
def verify_root():
    """
    Verificación del webhook en la ruta raíz para Facebook/Meta.
    Maneja la verificación inicial cuando configuras el webhook en Facebook.
    """
    mode = request.args.get("hub.mode")
    token = request.args.get("hub.verify_token")
    challenge = request.args.get("hub.challenge")

    logging.info(f"🔍 Verificación webhook en /: mode={mode}, token={token}, challenge={challenge}")

    if mode == "subscribe" and token == CONFIG["VERIFY_TOKEN"]:
        logging.info("✅ Webhook verificado correctamente en ruta raíz.")
        return challenge, 200
    else:
        logging.warning(f"❌ Verificación webhook fallida en ruta raíz: token inválido. Esperado: {CONFIG['VERIFY_TOKEN']}, Recibido: {token}")
        return "Forbidden", 403

@app.route("/webhook", methods=["GET"])
def verify():
    """
    Verificación del webhook en /webhook (mantenido para compatibilidad).
    """
    mode = request.args.get("hub.mode")
    token = request.args.get("hub.verify_token")
    challenge = request.args.get("hub.challenge")

    logging.info(f"🔍 Verificación webhook en /webhook: mode={mode}, token={token}, challenge={challenge}")

    if mode == "subscribe" and token == CONFIG["VERIFY_TOKEN"]:
        logging.info("✅ Webhook verificado correctamente en /webhook.")
        return challenge, 200
    else:
        logging.warning(f"❌ Verificación webhook fallida en /webhook: token inválido. Esperado: {CONFIG['VERIFY_TOKEN']}, Recibido: {token}")
        return "Forbidden", 403

@app.route("/", methods=["POST"])
def webhook_post_root():
    """
    Endpoint POST para recibir webhooks en la ruta raíz.
    """
    return webhook_post_handler()

@app.route("/webhook", methods=["POST"])
def webhook_post():
    """
    Endpoint POST para recibir webhooks en /webhook.
    """
    return webhook_post_handler()

def webhook_post_handler():
    """
    Función común para manejar webhooks POST tanto en / como en /webhook.
    """
    # Leer body en bytes para validar HMAC exacto
    body_bytes = request.get_data()
    # Verificar firma
    header_sig = request.headers.get("X-Hub-Signature-256") or request.headers.get("X-Hub-Signature")
    if not verify_signature(body_bytes, header_sig):
        logging.warning("🔒 Firma inválida - rechazando POST.")
        return "Invalid signature", 403

    # Intentar parsear JSON
    try:
        data = json.loads(body_bytes.decode("utf-8"))
    except Exception:
        logging.exception("❌ Error decodificando JSON del POST.")
        return "Bad Request", 400

    # Guardar raw si debug
    if CONFIG["DEBUG"]:
        try:
            ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
            with open(f"logs/raw_{ts}.json", "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
        except Exception:
            logging.exception("No se pudo escribir raw log.")

    # Validar estructura básica
    if not is_valid_whatsapp_payload(data):
        logging.warning("Estructura de payload no válida o no WhatsApp event.")
        # Aun así devolvemos 200 porque puede ser otro evento (Meta reintenta si 4xx/5xx)
        # Guardar para revisión y seguir
        try:
            with open("logs/unknown_payloads.log", "a", encoding="utf-8") as f:
                f.write(f"{datetime.utcnow().isoformat()} - {json.dumps(data)}\n")
        except Exception:
            pass
        return "", 200

    # Encolar para procesamiento asíncrono
    try:
        event_queue.put_nowait({"payload": data, "retries": 0})
    except queue.Full:
        logging.error("Cola llena, no se puede procesar el evento actualmente.")
        return "Server busy", 503

    # Responder rápido
    return "", 200

@app.route("/test_recibir", methods=["POST"])
def test_recibir():
    """Endpoint para pruebas y callbacks - devuelve lo que recibe"""
    try:
        payload = request.get_json(force=True)
    except Exception:
        return jsonify({"error": "invalid json"}), 400
    logging.info("📞 Callback de prueba recibido.")
    logging.debug("Payload callback: %s", json.dumps(payload, ensure_ascii=False))
    return jsonify({"status": "ok", "received_at": datetime.utcnow().isoformat()}), 200

@app.route("/status", methods=["GET"])
def status():
    return jsonify({
        "status": "active",
        "version": "1.0.0",
        "webhook_url": request.base_url.replace("/status", "/webhook"),
        "ws_endpoint": CONFIG["WS_ENDPOINT"],
        "queue_size": event_queue.qsize(),
        "seen_messages_cached": len(seen_messages),
        "timestamp": datetime.utcnow().isoformat()
    }), 200

# ---------------------------
# Inicio del worker y servidor
# ---------------------------
def start_worker_thread():
    t = threading.Thread(target=worker_loop, daemon=True, name="wh-worker")
    t.start()
    return t

if __name__ == "__main__":
    worker_thread = start_worker_thread()
    # SSL handling: busca certs de Let's Encrypt primero, luego en ../config/
    letsencrypt_cert = "/etc/letsencrypt/live/oficina.itego.com.ar/fullchain.pem"
    letsencrypt_key = "/etc/letsencrypt/live/oficina.itego.com.ar/privkey.pem"
    # Buscar certificados en la carpeta config del proyecto (un nivel arriba)
    project_root = os.path.dirname(os.path.dirname(__file__))
    local_cert_path = os.path.join(project_root, "config", "cert.pem")
    local_key_path = os.path.join(project_root, "config", "key.pem")

    ssl_context = None
    if CONFIG["USE_SSL_IF_AVAILABLE"]:
        # Primero intentar con certificados de Let's Encrypt
        if os.path.exists(letsencrypt_cert) and os.path.exists(letsencrypt_key):
            import ssl
            ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
            ssl_context.load_cert_chain(certfile=letsencrypt_cert, keyfile=letsencrypt_key)
            port = CONFIG["SSL_PORT"]
            logging.info("🔐 SSL habilitado con certificados de Let's Encrypt (oficina.itego.com.ar).")
        # Si no, usar certificados locales
        elif os.path.exists(local_cert_path) and os.path.exists(local_key_path):
            import ssl
            ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
            ssl_context.load_cert_chain(certfile=local_cert_path, keyfile=local_key_path)
            port = CONFIG["SSL_PORT"]
            logging.info("🔐 SSL habilitado con certificados locales.")
        else:
            port = CONFIG["WEBHOOK_PORT"]
            logging.warning("⚠️ SSL no habilitado o certificados no encontrados. Ejecutando en HTTP (no recomendado en producción).")
    else:
        port = CONFIG["WEBHOOK_PORT"]

    logging.info("🚀 Iniciando Webhook en %s:%s (debug=%s)", CONFIG["WEBHOOK_HOST"], port, CONFIG["DEBUG"])
    # Flask built-in server is OK para desarrollo; en producción usar gunicorn/uvicorn + reverse proxy.
    app.run(host=CONFIG["WEBHOOK_HOST"], port=port, debug=CONFIG["DEBUG"], ssl_context=ssl_context)