#!/usr/bin/env python3
"""
Hotmart -> Meta CAPI (Conversions API) — Borrello / Passo a Passo da Radiestesia
================================================================================

Endpoint publico:  https://hotmart-webhook.agentesclimb.us/borrello
Porta local:       8950  (Traefik -> 172.18.0.1:8950)

Fluxo:
  1. Recebe POST da Hotmart (postback / webhook 2.0).
  2. Valida HOTTOK (header X-HOTMART-HOTTOK  ou  campo body 'hottok').
  3. Idempotencia por transaction_id em SQLite (dedupe defensivo — Hotmart
     reenvia o mesmo evento em caso de retry).
  4. Traduz o payload no evento Meta CAPI equivalente:
        - PURCHASE_APPROVED   -> Purchase (event_id = transaction_id)
        - PURCHASE_COMPLETE   -> Purchase (mesmo event_id -> dedupe local
                                 evita reenvio; se APPROVED nao chegou
                                 antes, este vira o primeiro Purchase)
        - PURCHASE_REFUNDED   -> Purchase com value negativo (opt_out=false)
                                 usando custom event 'Refund' (rec. Meta)
        - PURCHASE_CHARGEBACK -> Custom event 'Chargeback'
        - PURCHASE_CANCELED   -> ignorado (nunca virou venda)
  5. event_id = transaction_id da Hotmart (dedupe com pixel do browser).
  6. Envia pro Meta Graph API v21.0 e loga request+response.

Config em /opt/mia/config/meta_capi_borrello.env
Log em /opt/mia/logs/hotmart_capi_borrello.log
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import re
import sqlite3
import sys
import threading
import time
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any

import requests
from flask import Flask, jsonify, request

BASE_DIR = Path(__file__).resolve().parent

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_FILE = Path("/opt/mia/logs/hotmart_capi_borrello.log")
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)

fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024,
                                   backupCount=5, encoding="utf-8")
file_handler.setFormatter(fmt)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(fmt)
logging.basicConfig(level=logging.INFO, handlers=[file_handler, stream_handler])
log = logging.getLogger("hotmart_capi")

# ---------------------------------------------------------------------------
# Env loading (padrao do repo: dotenv + fallback manual)
# ---------------------------------------------------------------------------
try:
    from dotenv import load_dotenv
    load_dotenv("/opt/mia/.env")
    load_dotenv("/opt/mia/config/meta_capi_borrello.env")
    load_dotenv("/opt/mia/config/hotmart_borrello.env")
except ImportError:
    for envfile in ("/opt/mia/.env",
                    "/opt/mia/config/meta_capi_borrello.env",
                    "/opt/mia/config/hotmart_borrello.env"):
        if not os.path.exists(envfile):
            continue
        with open(envfile, encoding="utf-8") as f:
            for raw in f:
                line = raw.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                k, v = line.split("=", 1)
                os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HOTTOK = os.getenv("HOTMART_HOTTOK", "").strip()

META_PIXEL_ID = os.getenv("META_PIXEL_ID_BORRELLO", "464623303880507").strip()
META_CAPI_TOKEN = os.getenv("META_CAPI_TOKEN", "").strip()
META_API_VERSION = os.getenv("META_API_VERSION", "v21.0").strip()
META_TEST_EVENT_CODE = os.getenv("META_TEST_EVENT_CODE", "").strip()

# Se True e nao houver CAPI token, apenas loga sem enviar
STUB_MODE = not META_CAPI_TOKEN

EVENT_SOURCE_URL = os.getenv(
    "EVENT_SOURCE_URL",
    "https://passoapasso.franciscoborrello.com.br/",
).strip()

DB_PATH = Path(os.getenv("DEDUPE_DB", str(BASE_DIR / "dedupe.db")))
PORT = int(os.getenv("PORT", "8950"))
HOST = os.getenv("HOST", "0.0.0.0")

META_GRAPH_URL = (
    f"https://graph.facebook.com/{META_API_VERSION}/{META_PIXEL_ID}/events"
)

# Eventos Hotmart que a gente age em cima
# APPROVED e COMPLETE ambos mapeiam pra Purchase; como event_id = transaction_id,
# o dedupe SQLite garante que a Meta so recebe UMA vez.
HANDLED_STATUS = {
    "APPROVED": "Purchase",
    "PURCHASE_APPROVED": "Purchase",
    "COMPLETE": "Purchase",
    "PURCHASE_COMPLETE": "Purchase",
    "REFUNDED": "Refund",
    "PURCHASE_REFUNDED": "Refund",
    "CHARGEBACK": "Chargeback",
    "PURCHASE_CHARGEBACK": "Chargeback",
}

# ---------------------------------------------------------------------------
# Dedupe DB (SQLite)
# ---------------------------------------------------------------------------
_db_lock = threading.Lock()


def _db_conn():
    conn = sqlite3.connect(str(DB_PATH))
    conn.execute("""
        CREATE TABLE IF NOT EXISTS processed_events (
            event_id       TEXT PRIMARY KEY,
            event_name     TEXT NOT NULL,
            transaction_id TEXT,
            received_at    INTEGER NOT NULL,
            meta_status    INTEGER,
            meta_response  TEXT,
            payload        TEXT
        )
    """)
    conn.commit()
    return conn


def already_processed(event_id: str) -> bool:
    with _db_lock, _db_conn() as conn:
        row = conn.execute(
            "SELECT 1 FROM processed_events WHERE event_id = ?",
            (event_id,),
        ).fetchone()
        return row is not None


def mark_processed(event_id: str, event_name: str, transaction_id: str,
                   meta_status: int | None, meta_response: str,
                   payload: dict[str, Any]):
    with _db_lock, _db_conn() as conn:
        conn.execute(
            """INSERT OR REPLACE INTO processed_events
               (event_id, event_name, transaction_id, received_at,
                meta_status, meta_response, payload)
               VALUES (?, ?, ?, ?, ?, ?, ?)""",
            (event_id, event_name, transaction_id, int(time.time()),
             meta_status, meta_response[:4000],
             json.dumps(payload, ensure_ascii=False)[:20000]),
        )
        conn.commit()


# ---------------------------------------------------------------------------
# Hashing helpers (Meta CAPI PII requirements)
# ---------------------------------------------------------------------------
def sha256(v: str) -> str:
    return hashlib.sha256(v.strip().lower().encode("utf-8")).hexdigest()


def norm_phone(raw: str) -> str:
    """Meta espera E.164 sem '+' nem separadores, lowercase, hash SHA256."""
    if not raw:
        return ""
    digits = re.sub(r"\D+", "", raw)
    if not digits:
        return ""
    return digits


def norm_email(raw: str) -> str:
    if not raw:
        return ""
    return raw.strip().lower()


def hash_or_empty(v: str) -> str:
    v = (v or "").strip()
    if not v:
        return ""
    return sha256(v)


# ---------------------------------------------------------------------------
# Hotmart -> CAPI translation
# ---------------------------------------------------------------------------
def extract_event(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]:
    """
    Retorna (status_raw, data).
    Suporta o formato do Postback 2.0 da Hotmart:
      { "id": "...", "event": "PURCHASE_APPROVED",
        "data": { "purchase": {...}, "buyer": {...}, "product": {...} } }
    E o formato de Postback legado (form-encoded flatten com 'prod', 'email', etc).
    """
    # Postback 2.0
    if isinstance(payload.get("data"), dict):
        status = (payload.get("event")
                  or payload["data"].get("purchase", {}).get("status", "")
                  or "")
        return status.upper(), payload["data"]

    # Legado (form data) — a Hotmart em contas antigas manda tudo flat
    return (payload.get("status", "") or "").upper(), payload


def _get(d: dict, *paths, default=None):
    """Busca por dot-path aninhado: _get(d, 'purchase.transaction')."""
    for path in paths:
        cur = d
        ok = True
        for part in path.split("."):
            if not isinstance(cur, dict) or part not in cur:
                ok = False
                break
            cur = cur[part]
        if ok and cur not in (None, ""):
            return cur
    return default


def to_unix(ts: Any) -> int:
    """Aceita ms (Hotmart 2.0), seconds, ISO string, dd/mm/aaaa hh:mm."""
    if ts is None:
        return int(time.time())
    if isinstance(ts, (int, float)):
        # ms se claramente > 1e12
        return int(ts / 1000) if ts > 1e12 else int(ts)
    if isinstance(ts, str):
        # ISO
        try:
            dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
            return int(dt.timestamp())
        except Exception:
            pass
        # BR
        for fmt_str in ("%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M", "%d/%m/%Y"):
            try:
                dt = datetime.strptime(ts, fmt_str).replace(tzinfo=timezone.utc)
                return int(dt.timestamp())
            except Exception:
                continue
    return int(time.time())


def build_capi_event(status: str, data: dict[str, Any],
                     tracking: dict[str, str] | None = None) -> dict[str, Any] | None:
    """
    Constroi 1 evento CAPI a partir do payload Hotmart.
    Retorna None se status nao mapeia (ignorar silenciosamente).
    """
    event_name = HANDLED_STATUS.get(status)
    if not event_name:
        return None

    tx_id = str(_get(data, "purchase.transaction", "transaction", default="")).strip()
    if not tx_id:
        log.warning("Payload sem transaction_id — skip: %s",
                    json.dumps(data)[:400])
        return None

    # Timing
    ts_unix = to_unix(_get(data,
                           "purchase.order_date",
                           "purchase.approved_date",
                           "purchase.approvedDate",
                           "purchase_date",
                           default=None))
    # Meta rejeita event_time > 7 dias no passado — clampa
    now = int(time.time())
    if ts_unix < now - 7 * 24 * 3600 + 60:
        ts_unix = now - 7 * 24 * 3600 + 60

    # Buyer PII
    email = norm_email(str(_get(data, "buyer.email", "email", default="")))
    phone_raw = str(_get(data,
                         "buyer.checkout_phone",
                         "buyer.phone",
                         "phone",
                         default=""))
    phone = norm_phone(phone_raw)
    first_name = str(_get(data, "buyer.name", "name", default="")).split(" ")[0]
    last_name_parts = str(_get(data, "buyer.name", "name", default="")).split(" ")[1:]
    last_name = " ".join(last_name_parts) if last_name_parts else ""
    country = str(_get(data, "buyer.address.country_iso",
                       "buyer.address.country",
                       default="BR")).lower()[:2] or "br"

    user_data: dict[str, Any] = {}
    if email:
        user_data["em"] = [sha256(email)]
    if phone:
        user_data["ph"] = [sha256(phone)]
    if first_name:
        user_data["fn"] = [sha256(first_name)]
    if last_name:
        user_data["ln"] = [sha256(last_name)]
    if country:
        user_data["country"] = [sha256(country)]

    # Tracking cookies (se disponiveis)
    tracking = tracking or {}
    fbc = tracking.get("fbc") or _get(data, "purchase.tracking.fbc",
                                      "purchase.trackings.fbc",
                                      "src_fbc", default="")
    fbp = tracking.get("fbp") or _get(data, "purchase.tracking.fbp",
                                      "purchase.trackings.fbp",
                                      "src_fbp", default="")
    ip = tracking.get("client_ip_address") or _get(data,
                                                   "purchase.buyer_ip",
                                                   "buyer_ip",
                                                   default="")
    ua = tracking.get("client_user_agent") or _get(data,
                                                   "purchase.tracking.user_agent",
                                                   default="")
    if fbc:
        user_data["fbc"] = fbc
    if fbp:
        user_data["fbp"] = fbp
    if ip:
        user_data["client_ip_address"] = ip
    if ua:
        user_data["client_user_agent"] = ua

    # Valor
    value = _get(data,
                 "purchase.price.value",
                 "purchase.full_price.value",
                 "purchase.offer.price.value",
                 "price",
                 default=0)
    try:
        value = float(value or 0)
    except Exception:
        value = 0.0
    currency = str(_get(data,
                        "purchase.price.currency_value",
                        "purchase.price.currency",
                        "currency",
                        default="BRL")).upper()[:3] or "BRL"

    product_id = str(_get(data, "product.id", "prod", default="")).strip()
    product_name = str(_get(data, "product.name", "prod_name",
                            default="")).strip()
    # Se for assinatura, concatena nome do plano p/ granularidade
    plan_name = str(_get(data, "subscription.plan.name",
                         "purchase.subscription.plan.name",
                         default="")).strip()
    if product_name and plan_name and plan_name.lower() not in product_name.lower():
        product_name = f"{product_name} - {plan_name}"

    # Sinal negativo em refund/chargeback: mantem o mesmo Purchase event_name
    # mas ajusta value pra negativo — assim o Meta desaprende. Alternativa:
    # custom_events. Vamos priorizar Purchase negativo p/ refund (Meta orientacao)
    # e custom p/ chargeback (mais raro).
    event_time = ts_unix
    if event_name == "Refund":
        # Meta reconhece: mesmo event_name Purchase, valor negativo, event_id novo
        event_name_out = "Purchase"
        value = -abs(value)
        event_id = f"refund_{tx_id}"
    elif event_name == "Chargeback":
        event_name_out = "Purchase"
        value = -abs(value)
        event_id = f"chargeback_{tx_id}"
    else:
        event_name_out = "Purchase"
        event_id = tx_id  # dedupe com pixel browser

    ev: dict[str, Any] = {
        "event_name": event_name_out,
        "event_time": event_time,
        "action_source": "website",
        "event_source_url": EVENT_SOURCE_URL,
        "event_id": event_id,
        "user_data": user_data,
        "custom_data": {
            "currency": currency,
            "value": round(value, 2),
        },
    }
    if product_id:
        ev["custom_data"]["content_ids"] = [product_id]
    if product_name:
        ev["custom_data"]["content_name"] = product_name
    # content_type/category ajudam o Meta a agrupar no relatorio
    ev["custom_data"]["content_type"] = "product"
    ev["custom_data"]["content_category"] = "curso_online"

    return ev


# ---------------------------------------------------------------------------
# Meta CAPI sender
# ---------------------------------------------------------------------------
def send_to_meta(event: dict[str, Any]) -> tuple[int, str]:
    if STUB_MODE:
        log.warning("STUB_MODE ativo (META_CAPI_TOKEN vazio). "
                    "Nao envia — apenas loga o evento.")
        log.info("STUB event body: %s", json.dumps(event, ensure_ascii=False))
        return 0, "stub"

    body: dict[str, Any] = {"data": [event]}
    if META_TEST_EVENT_CODE:
        body["test_event_code"] = META_TEST_EVENT_CODE

    try:
        r = requests.post(
            META_GRAPH_URL,
            params={"access_token": META_CAPI_TOKEN},
            json=body,
            timeout=15,
        )
        log.info("Meta CAPI response [%s]: %s",
                 r.status_code, r.text[:500])
        return r.status_code, r.text
    except requests.RequestException as exc:
        log.exception("Falha ao chamar Meta CAPI: %s", exc)
        return -1, str(exc)


# ---------------------------------------------------------------------------
# Flask app
# ---------------------------------------------------------------------------
app = Flask(__name__)


def _extract_hottok(req) -> str:
    """Hotmart 2.0 = header X-HOTMART-HOTTOK. Legado = campo body 'hottok'."""
    hdr = req.headers.get("X-HOTMART-HOTTOK", "").strip()
    if hdr:
        return hdr
    if req.is_json:
        return (req.get_json(silent=True) or {}).get("hottok", "").strip()
    return (req.form.get("hottok", "") or "").strip()


def _extract_tracking(req) -> dict[str, str]:
    """Metadados HTTP do request (fbc/fbp normalmente nao vem no postback,
    mas se a landing tiver feito bind server-side, cai aqui)."""
    return {
        "client_ip_address": req.headers.get("X-Forwarded-For",
                                             req.remote_addr or "").split(",")[0].strip(),
        "client_user_agent": req.headers.get("User-Agent", "").strip(),
    }


@app.route("/health", methods=["GET"])
def health():
    return jsonify({
        "status": "ok",
        "stub_mode": STUB_MODE,
        "pixel_id": META_PIXEL_ID,
        "hottok_configured": bool(HOTTOK),
        "capi_token_configured": bool(META_CAPI_TOKEN),
    }), 200


@app.route("/borrello", methods=["POST"])
def borrello_webhook():
    # 1. Autenticacao HOTTOK
    if not HOTTOK:
        log.error("HOTMART_HOTTOK nao configurado — recusando request.")
        return jsonify({"ok": False, "error": "hottok_not_configured"}), 503

    received = _extract_hottok(request)
    if received != HOTTOK:
        log.warning("HOTTOK invalido. Recebido: %r  IP: %s",
                    received[:8] + "..." if received else "(vazio)",
                    request.headers.get("X-Forwarded-For", request.remote_addr))
        return jsonify({"ok": False, "error": "invalid_hottok"}), 401

    # 2. Parse payload
    payload = request.get_json(silent=True) or request.form.to_dict() or {}

    status, data = extract_event(payload)
    prod_id = str(_get(data, "product.id", "prod", default="") or "").strip()
    prod_name = str(_get(data, "product.name", "prod_name", default="") or "").strip()
    tx = str(_get(data, "purchase.transaction", "transaction", default="") or "").strip()
    log.info("[recv] event=%s tx=%s product_id=%s product_name=%r hotmart_id=%s size=%d",
             payload.get("event") or payload.get("status") or status,
             tx,
             prod_id,
             prod_name,
             payload.get("id"),
             len(json.dumps(payload)))
    if not status:
        log.info("Sem status — 204.")
        return "", 204

    if status not in HANDLED_STATUS:
        log.info("Status %s nao mapeado, ignorando (200).", status)
        return jsonify({"ok": True, "ignored": status}), 200

    # 3. Traduz + envia
    tracking = _extract_tracking(request)
    event = build_capi_event(status, data, tracking=tracking)
    if not event:
        return jsonify({"ok": True, "skipped": "no_event"}), 200

    event_id = event["event_id"]

    # 4. Dedupe
    if already_processed(event_id):
        log.info("Dedupe: event_id %s ja processado — 200 OK.", event_id)
        return jsonify({"ok": True, "deduped": event_id}), 200

    status_code, response_text = send_to_meta(event)
    mark_processed(event_id, event["event_name"],
                   str(_get(data, "purchase.transaction", "transaction",
                            default="")),
                   status_code, response_text, payload)

    # Meta retorna 200 se aceitou. Se der 4xx a gente ainda devolve 200 pra
    # Hotmart nao entrar em retry infinito (o problema ja foi logado).
    return jsonify({
        "ok": True,
        "event_id": event_id,
        "meta_status": status_code,
    }), 200


# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    log.info("=== Hotmart CAPI Borrello ===")
    log.info("Pixel:        %s", META_PIXEL_ID)
    log.info("STUB_MODE:    %s  (CAPI token %s)",
             STUB_MODE, "vazio" if STUB_MODE else "configurado")
    log.info("HOTTOK:       %s", "configurado" if HOTTOK else "AUSENTE")
    log.info("Test event:   %s", META_TEST_EVENT_CODE or "(prod)")
    log.info("DB dedupe:    %s", DB_PATH)
    log.info("Bind:         %s:%d", HOST, PORT)
    app.run(host=HOST, port=PORT, threaded=True)
