# backend/data_manager.py  (v2 - reescrito completo)
"""Capa de acceso a datos del módulo LEADS."""
import os
import requests
from dotenv import load_dotenv

load_dotenv()

# ── Mapa de campañas (frase -> programa/sede/rango fechas), igual que el PBI ──
CAMPANIAS = [
    ('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'),
    ('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'),
    ('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
    ('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
    ('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
    ('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
    ('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
    ('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
    ('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
    ('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
    ('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
    ('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
    ('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
    ('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'),
    ('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
    ('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
    ('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
    ('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
    ('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'),
    ('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
    ('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
    ('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
    (' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
    (' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
    (' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
    (' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
    (' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
    ('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
    ('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
    ('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
    ('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
    ('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
    ('📱 Estoy interesado Instalación', 'VRF', '81a', 'Lima', '-', 'Pauta_wsp', '2026-06-25', '2026-08-30'),
    ('🧊 Más información del Seminario', 'VRF', '82a', 'Arequipa', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'),
    ('📐 Más información del Seminario', 'VRF', '83a', 'Trujillo', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'),
    ('👾 Más información del Seminario', 'VRF', '84a', 'Piura', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'),
    ('🛠️ más info del Seminario Virtual ', 'Diseño de Chillers', '85a', 'Lima', '-', 'Pauta_wsp', '2026-07-01', '2026-09-30'),
    ('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'),
    ('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'),
    ('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
    ('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
    ('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
    ('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'),
    ('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'),
    ('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'),
    ('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
    ('¡Hola! ⚡', 'TEAC', '230', 'Trujillo', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'),
    ('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
    ('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'),
    ('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
    ('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
    ('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
    ('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
    ('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
    ('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
    ('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
    ('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
    ('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
    ('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
    ('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
    ('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
    ('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
    ('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
    ('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
    ('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
    ('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
    ('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
    ('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
    ('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'),
    ('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
    ('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
    ('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
    ('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'),
    ('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
    ('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
    ('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
    ('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
    ('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
    ('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'),
    ('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
    ('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
    ('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
    ('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
    ('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31'),
]


def _values_sql():
    """Construye el bloque VALUES (...) del mapa de campañas para el JOIN."""
    filas = []
    for frase, cargo, codigo, sede, dia, origen, fi, ff in CAMPANIAS:
        f = frase.replace("'", "''")
        filas.append(f"('{f}','{cargo}','{codigo}','{sede}','{dia}','{origen}','{fi}','{ff}')")
    return ",\n".join(filas)


class DataManager:
    def __init__(self):
        self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
        self.pg_db   = os.getenv("PG_DATABASE", "chatwoot_production")
        self.pg_user = os.getenv("PG_USER", "postgres")
        self.pg_pass = os.getenv("PG_PASSWORD", "")
        self.pg_port = os.getenv("PG_PORT", "5432")
        self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80")
        self.sql_db     = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
        self.sql_user   = os.getenv("SQL_USERNAME", "")
        self.sql_pass   = os.getenv("SQL_PASSWORD", "")
        self.sql_driver = os.getenv("SQL_DRIVER", "{SQL Server}")

    def pg_conn(self):
        import psycopg2
        return psycopg2.connect(
            host=self.pg_host, dbname=self.pg_db, user=self.pg_user,
            password=self.pg_pass, port=self.pg_port,
        )

    def sql_conn(self):
        import pyodbc
        conn_str = (
            f"DRIVER={self.sql_driver};SERVER={self.sql_server};"
            f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}"
        )
        return pyodbc.connect(conn_str)

    # ── CHATWOOT: leads de pauta (mapa de campañas, dedup por mensaje) ──
    def traer_leads_chatwoot(self):
        sql = f"""
        SELECT DISTINCT ON (m.id)
            -- Normalización IGUAL que el PBI: quitar '+51' y '+', quedando el número
            REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono,
            u.name AS asesor,
            m.created_at AS fecha_creada,
            cv.cached_label_list AS etiquetas,
            map.cargo AS programa, map.sede AS sede,
            map.codigo AS codigo, map.origen AS origen
        FROM messages m
        JOIN conversations cv ON m.conversation_id = cv.id
        JOIN contacts c ON cv.contact_id = c.id
        LEFT JOIN users u ON cv.assignee_id = u.id
        JOIN (VALUES
{_values_sql()}
        ) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin)
            ON m.content LIKE '%' || map.frase_busqueda || '%'
            AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP)
            AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day'
        WHERE m.sender_type = 'Contact'
        ORDER BY m.id ASC
        """
        conn = self.pg_conn()
        cur = conn.cursor()
        cur.execute(sql)
        cols = [d[0] for d in cur.description]
        filas = [dict(zip(cols, row)) for row in cur.fetchall()]
        conn.close()
        return filas

    # ── SQL SERVER: cursos ──
    def traer_cursos(self):
        sql = """
        SELECT rp.num_indice, rp.dsc_det_programa, p.dsc_programa,
            rp.cod_frecuencia, rp.fch_inicio, rp.cod_estado,
            (SELECT COUNT(*) FROM sgeca_matricula m
                WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
                  AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales,
            (SELECT COUNT(*) FROM sgeca_matricula m
                WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
                  AND m.cod_estado = 'RET') AS Inscritos_Retirados,
            (SELECT COUNT(*) FROM sgeca_matricula m
                WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
                  AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos
        FROM sgede_RP_programa rp
        INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa
        WHERE YEAR(rp.fch_inicio) IN (2025, 2026)
        ORDER BY rp.fch_inicio ASC
        """
        conn = self.sql_conn()
        cur = conn.cursor()
        cur.execute(sql)
        cols = [d[0] for d in cur.description]
        filas = [dict(zip(cols, row)) for row in cur.fetchall()]
        conn.close()
        return filas

    # ── SUPABASE: mapa num_indice → pauta (tabla basebi_programacion) ──
    def traer_pauta_cursos(self):
        """Devuelve dict {str(num_indice): {pauta, estado, contar}} desde Supabase.
        Fuente: tabla BaseBI_Programacion de SharePoint, subida a Supabase."""
        url = os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("SUPABASE_PAUTA_KEY", "")
        tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion")
        if not url or not key:
            return {}
        try:
            r = requests.get(
                f"{url}/rest/v1/{tabla}",
                params={"select": "num_indice,pauta_codigo,estado,contar"},
                headers={"apikey": key, "Authorization": f"Bearer {key}"},
                timeout=30,
            )
            r.raise_for_status()
            mapa = {}
            for row in r.json():
                ni = row.get("num_indice")
                if ni is None:
                    continue
                pa = row.get("pauta_codigo")
                mapa[str(ni)] = {
                    "pauta": (str(pa).strip() if pa not in (None, "") else None),
                    "estado": (str(row.get("estado") or "").strip().upper()),
                    "contar": (str(row.get("contar") or "").strip().upper()),
                }
            return mapa
        except Exception as e:
            print(f"[traer_pauta_cursos] {e}")
            return {}

    # ── SUPABASE: valores de programa/sede no identificados (para la campanita) ──
    def traer_no_identificados(self):
        """Compara cartera_junta vs alias_normalizacion y devuelve los valores de
        programa/sede que NO están en el diccionario (correcto).
        Retorna {'programa': [{'valor','veces'}], 'sede': [...]}."""
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t_cart = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
        t_alias = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion")
        vacio = {"programa": [], "sede": []}
        if not url or not key:
            return vacio
        headers = {"apikey": key, "Authorization": f"Bearer {key}"}
        try:
            # 1) valores conocidos (correcto) por tipo
            r = requests.get(f"{url}/rest/v1/{t_alias}",
                             params={"select": "tipo,correcto"}, headers=headers, timeout=30)
            r.raise_for_status()
            conocidos = {"PROGRAMA": set(), "SEDE": set()}
            for row in r.json():
                tipo = str(row.get("tipo") or "").upper()
                corr = " ".join(str(row.get("correcto") or "").upper().split())
                if tipo in conocidos:
                    conocidos[tipo].add(corr)

            # 2) traer de cartera_junta los valores que NO estén en los conocidos
            def _desconocidos(columna, tipo):
                conj = conocidos.get(tipo, set())
                if not conj:
                    return []
                lista = ",".join('"' + v.replace('"', '') + '"' for v in conj)
                rr = requests.get(
                    f"{url}/rest/v1/{t_cart}",
                    params={"select": columna, columna: f"not.in.({lista})", "limit": "5000"},
                    headers=headers, timeout=30)
                rr.raise_for_status()
                cont = {}
                for row in rr.json():
                    v = " ".join(str(row.get(columna) or "").upper().split())
                    cont[v] = cont.get(v, 0) + 1
                return [{"valor": k, "veces": n}
                        for k, n in sorted(cont.items(), key=lambda x: -x[1])]

            return {"programa": _desconocidos("programa", "PROGRAMA"),
                    "sede": _desconocidos("sede", "SEDE")}
        except Exception as e:
            print(f"[traer_no_identificados] {e}")
            return vacio

    # ── SUPABASE: diccionario alias (para normalizar campanias como la cartera) ──
    def traer_alias_map(self):
        """{'PROGRAMA': {alias:correcto}, 'SEDE': {alias:correcto}}."""
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion")
        out = {"PROGRAMA": {}, "SEDE": {}}
        if not url or not key:
            return out
        try:
            r = requests.get(f"{url}/rest/v1/{t}", params={"select": "tipo,alias,correcto"},
                             headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
            r.raise_for_status()
            for row in r.json():
                tipo = str(row.get("tipo") or "").upper()
                al = " ".join(str(row.get("alias") or "").upper().split())
                co = str(row.get("correcto") or "")
                if tipo in out:
                    out[tipo][al] = co
        except Exception as e:
            print(f"[traer_alias_map] {e}")
        return out

    # ── SUPABASE: mapa codigo → (sede, programa) desde campanias (normalizado) ──
    def traer_campanias_map(self):
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
        out = {}
        if not url or not key:
            return out
        alias = self.traer_alias_map()

        def _norm(v, tipo):
            s = " ".join(str(v or "").upper().split())
            return alias.get(tipo, {}).get(s, s)

        try:
            r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"},
                             headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
            r.raise_for_status()
            for row in r.json():
                cod = " ".join(str(row.get("codigo") or "").upper().split())
                if not cod:
                    continue
                out[cod] = (_norm(row.get("sede"), "SEDE"), _norm(row.get("cargo"), "PROGRAMA"))
        except Exception as e:
            print(f"[traer_campanias_map] {e}")
        return out

    # ── SUPABASE: filas de cartera_junta para contar (telefono, sede, programa, fecha) ──
    def traer_cartera_rows(self):
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
        out = []
        if not url or not key:
            return out
        headers = {"apikey": key, "Authorization": f"Bearer {key}"}
        paso, desde = 1000, 0
        try:
            while True:
                r = requests.get(
                    f"{url}/rest/v1/{t}",
                    params={"select": "telefono,sede,programa,fecha_creada,asesor,es_origen,canal",
                            "offset": str(desde), "limit": str(paso)},
                    headers=headers, timeout=60)
                r.raise_for_status()
                data = r.json()
                if not data:
                    break
                out.extend(data)
                if len(data) < paso:
                    break
                desde += paso
        except Exception as e:
            print(f"[traer_cartera_rows] {e}")
        return out

    # ── SUPABASE: mapa pauta → [conjuntos de anuncios] (tabla conjunto_pauta) ──
    def traer_conjunto_pauta(self):
        """Devuelve dict {pauta(str): [conjunto, ...]} desde la tabla conjunto_pauta.
        Una pauta puede tener varios conjuntos de anuncios."""
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta")
        out = {}
        if not url or not key:
            return out
        try:
            r = requests.get(f"{url}/rest/v1/{t}",
                             params={"select": "conjunto,pauta"},
                             headers={"apikey": key, "Authorization": f"Bearer {key}"},
                             timeout=30)
            r.raise_for_status()
            for row in r.json():
                pa = str(row.get("pauta") or "").strip()
                co = " ".join(str(row.get("conjunto") or "").split())
                if not pa or not co:
                    continue
                out.setdefault(pa, []).append(co)
        except Exception as e:
            print(f"[traer_conjunto_pauta] {e}")
        return out

    # ── GOOGLE SHEET (CSV publicado): importe gastado por conjunto de anuncios ──
    def traer_meta_importe(self):
        """Lee el CSV publicado de Meta_Ads_Adsets y devuelve una lista de dicts:
        [{conjunto, importe, inicio}, ...] (una por fila del CSV, inicio = 'Inicio del informe').
        Se conserva la fecha para poder filtrar por periodo (Importe Pauta en el Mes)."""
        import csv, io
        url = os.getenv("META_CSV_URL", "")
        out = []
        if not url:
            return out
        try:
            r = requests.get(url, timeout=30)
            r.raise_for_status()
            r.encoding = "utf-8"
            rows = list(csv.reader(io.StringIO(r.text)))
            if not rows:
                return out
            enc = [" ".join(str(h or "").split()).lower() for h in rows[0]]

            def _idx(*claves):
                for k in claves:
                    for i, h in enumerate(enc):
                        if k in h:
                            return i
                return -1

            # OJO: buscar "nombre del conjunto..." primero (hay tambien "ID del conjunto...").
            i_conj = _idx("nombre del conjunto de anuncios", "nombre del conjunto")
            i_imp = _idx("importe gastado")
            i_ini = _idx("inicio del informe")
            i_res = _idx("resultados")
            if i_conj < 0 or i_imp < 0:
                print(f"[traer_meta_importe] columnas no encontradas: {enc}")
                return out
            for fila in rows[1:]:
                if len(fila) <= max(i_conj, i_imp):
                    continue
                co = " ".join(str(fila[i_conj] or "").split())
                if not co:
                    continue
                raw = str(fila[i_imp] or "").strip().replace(",", "")
                try:
                    val = float(raw) if raw else 0.0
                except ValueError:
                    val = 0.0
                rraw = str(fila[i_res] or "").strip().replace(",", "") if i_res >= 0 and len(fila) > i_res else ""
                try:
                    res = float(rraw) if rraw else 0.0
                except ValueError:
                    res = 0.0
                ini = str(fila[i_ini] or "").strip() if i_ini >= 0 and len(fila) > i_ini else ""
                out.append({"conjunto": co, "importe": val, "resultados": res, "inicio": ini})
        except Exception as e:
            print(f"[traer_meta_importe] {e}")
        return out

    # ── CHATWOOT: leads asignados (mensaje "Asignado a..."/auto-asignado, ultimo por telefono) ──
    def traer_leads_asignados(self):
        """Devuelve [{telefono, fecha_asignada(date, -5h), user_name}] tomando el
        mensaje de asignacion mas reciente por telefono."""
        sql = """
        SELECT * FROM (
            SELECT DISTINCT ON (c.phone_number)
                REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono,
                m.id AS message_id,
                (m.created_at - INTERVAL '5 hours') AS created_at,
                COALESCE(u.name, 'Sin Asesor') AS user_name
            FROM messages m
            JOIN conversations cv ON m.conversation_id = cv.id
            JOIN contacts c ON cv.contact_id = c.id
            LEFT JOIN users u ON cv.assignee_id = u.id
            WHERE (
                m.content LIKE 'Asignado a %' OR
                m.content LIKE '% auto-asignado%'
            )
            ORDER BY c.phone_number, m.id DESC
        ) AS t
        ORDER BY message_id DESC
        """
        conn = self.pg_conn()
        cur = conn.cursor()
        cur.execute(sql)
        cols = [d[0] for d in cur.description]
        filas = [dict(zip(cols, row)) for row in cur.fetchall()]
        conn.close()
        return filas

    # ── SUPABASE: actualizar/insertar la pauta de un conjunto en conjunto_pauta ──
    def upsert_conjunto_pauta(self, conjunto, pauta):
        """Si el conjunto ya existe en conjunto_pauta, actualiza su pauta; si no, lo crea.
        Si pauta viene vacia, elimina el vinculo (deja el conjunto sin pauta)."""
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta")
        h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
        co = str(conjunto).strip(); pa = str(pauta or "").strip()
        # ¿existe el conjunto?
        r = requests.get(f"{url}/rest/v1/{t}",
                         params={"select": "conjunto", "conjunto": f"eq.{co}"},
                         headers=h, timeout=30)
        r.raise_for_status()
        existe = len(r.json()) > 0
        if not pa:
            # sin pauta -> borrar vinculo si existia
            if existe:
                requests.delete(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"},
                                headers=h, timeout=30).raise_for_status()
            return {"conjunto": co, "pauta": None}
        if existe:
            requests.patch(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"},
                           headers=h, json={"pauta": pa}, timeout=30).raise_for_status()
        else:
            requests.post(f"{url}/rest/v1/{t}", headers=h,
                          json={"conjunto": co, "pauta": pa}, timeout=30).raise_for_status()
        return {"conjunto": co, "pauta": pa}

    # ── SUPABASE: conjuntos de anuncios SIN pauta conectada (del Sheet, no en conjunto_pauta) ──
    def traer_conjuntos_sin_pauta(self):
        """Lista de conjuntos del Google Sheet que NO estan en conjunto_pauta."""
        conj_pauta = self.traer_conjunto_pauta()   # {pauta: [conjuntos]}
        conectados = set()
        for cjs in conj_pauta.values():
            for c in cjs:
                conectados.add(" ".join(str(c).split()).upper())
        vistos = {}
        for f in self.traer_meta_importe():
            nombre = " ".join(str(f.get("conjunto")).split())
            if not nombre:
                continue
            if " ".join(nombre.split()).upper() not in conectados:
                vistos[nombre] = True
        return sorted(vistos.keys())

    # ── SUPABASE: programa_pautas -> {num_indice: [pautas]} (muchos-a-muchos) ──
    def traer_programa_pautas(self):
        url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas")
        out = {}
        if not url or not key:
            return out
        try:
            r = requests.get(f"{url}/rest/v1/{t}", params={"select": "num_indice,pauta"},
                             headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
            r.raise_for_status()
            for row in r.json():
                ni = str(row.get("num_indice") or "").strip()
                pa = str(row.get("pauta") or "").strip()
                if ni and pa:
                    out.setdefault(ni, []).append(pa)
        except Exception as e:
            print(f"[traer_programa_pautas] {e}")
        return out

    # ── SUPABASE: agregar un vinculo num_indice<->pauta en programa_pautas (upsert) ──
    def agregar_programa_pauta(self, num_indice, pauta):
        url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas")
        h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
        ni = str(num_indice).strip(); pa = str(pauta).strip()
        # ¿ya existe ese par?
        r = requests.get(f"{url}/rest/v1/{t}",
                         params={"select": "id", "num_indice": f"eq.{ni}", "pauta": f"eq.{pa}"},
                         headers=h, timeout=30)
        r.raise_for_status()
        if len(r.json()) > 0:
            return {"num_indice": ni, "pauta": pa, "ya_existia": True}
        rr = requests.post(f"{url}/rest/v1/{t}", headers=h,
                           json={"num_indice": ni, "pauta": pa}, timeout=30)
        rr.raise_for_status()
        return {"num_indice": ni, "pauta": pa, "ya_existia": False}

    # ── SUPABASE: num_indices que YA usan una pauta en basebi_programacion ──
    def num_indices_de_pauta(self, pauta):
        url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
        tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion")
        try:
            r = requests.get(f"{url}/rest/v1/{tabla}",
                             params={"select": "num_indice", "pauta_codigo": f"eq.{str(pauta).strip()}"},
                             headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
            r.raise_for_status()
            return [str(x.get("num_indice")).strip() for x in r.json() if x.get("num_indice") is not None]
        except Exception as e:
            print(f"[num_indices_de_pauta] {e}")
            return []

    # ── SUPABASE: guardar pauta y/o contar de un num_indice en basebi_programacion (upsert) ──
    def guardar_pauta_basebi(self, num_indice, pauta, contar=None):
        url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
        tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion")
        h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
        ni = str(num_indice).strip(); pa = str(pauta or "").strip()
        campos = {}
        if pa:
            campos["pauta_codigo"] = pa          # solo actualizar pauta si viene con valor
        if contar is not None:
            campos["contar"] = str(contar).strip().upper()   # "SI" / "NO"
        if not campos:
            return {"num_indice": ni, "sin_cambios": True}
        r = requests.get(f"{url}/rest/v1/{tabla}",
                         params={"select": "num_indice", "num_indice": f"eq.{ni}"},
                         headers=h, timeout=30)
        r.raise_for_status()
        existe = len(r.json()) > 0
        if existe:
            rr = requests.patch(f"{url}/rest/v1/{tabla}",
                                params={"num_indice": f"eq.{ni}"},
                                headers=h, json=campos, timeout=30)
        else:
            rr = requests.post(f"{url}/rest/v1/{tabla}",
                               headers=h, json={"num_indice": ni, **campos}, timeout=30)
        rr.raise_for_status()
        return {"num_indice": ni, "pauta": pa, "contar": campos.get("contar"), "creado": not existe}

    # ── SUPABASE: vincular un conjunto de anuncios a una pauta (insert en conjunto_pauta) ──
    def guardar_conjunto_pauta(self, conjunto, pauta):
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta")
        h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
        rr = requests.post(f"{url}/rest/v1/{t}", headers=h,
                           json={"conjunto": str(conjunto).strip(), "pauta": str(pauta).strip()},
                           timeout=30)
        rr.raise_for_status()
        return {"conjunto": conjunto, "pauta": pauta}

    # ── CHATWOOT: plantillas enviadas (para matriz de plantillas cobradas) ──
    def traer_plantillas(self):
        """Devuelve [{plantilla, telefono, user_name, created_at_peru,
        hora_anterior_contacto, siguiente_mensaje_1}] de mensajes de plantilla
        con content_attributes vacio (ISBLANK)."""
        sql = """
        SELECT
            m.additional_attributes -> 'template_params' ->> 'name' AS plantilla,
            REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono,
            u.name AS user_name,
            (m.created_at - INTERVAL '5 hours') AS created_at_peru,
            (SELECT (mp.created_at - INTERVAL '5 hours') FROM messages mp
             WHERE mp.conversation_id = m.conversation_id AND mp.id < m.id
               AND mp.sender_type = 'Contact' ORDER BY mp.id DESC LIMIT 1) AS hora_anterior_contacto,
            (SELECT m2.content FROM messages m2
             WHERE m2.conversation_id = m.conversation_id AND m2.id > m.id
               AND m2.sender_type = 'Contact' ORDER BY m2.id ASC LIMIT 1) AS siguiente_mensaje_1
        FROM messages m
        JOIN conversations cv ON m.conversation_id = cv.id
        JOIN contacts c ON cv.contact_id = c.id
        LEFT JOIN users u ON cv.assignee_id = u.id
        WHERE m.sender_type = 'User'
          AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
          AND (m.content_attributes IS NULL
               OR m.content_attributes::text = '{}'
               OR m.content_attributes::text = 'null')
        """
        conn = self.pg_conn()
        cur = conn.cursor()
        cur.execute(sql)
        cols = [d[0] for d in cur.description]
        filas = [dict(zip(cols, row)) for row in cur.fetchall()]
        conn.close()
        return filas

    # ── SUPABASE: campanias -> {codigo(pauta): {sede, cargo}} (crudo, sin normalizar) ──
    def traer_campanias_sede_cargo(self):
        url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
        key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
        t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
        out = {}
        if not url or not key:
            return out
        try:
            r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"},
                             headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
            r.raise_for_status()
            for row in r.json():
                cod = str(row.get("codigo") or "").strip()
                if cod:
                    out[cod] = {"sede": str(row.get("sede") or "").strip(),
                                "cargo": str(row.get("cargo") or "").strip()}
        except Exception as e:
            print(f"[traer_campanias_sede_cargo] {e}")
        return out

    # ── SQL SERVER: matrículas ──
    def traer_matriculas(self):
        sql = """
        SELECT
            sgeca_matricula.num_matricula, sgeca_matricula.num_indice,
            sgeca_matricula.fch_matricula,
            sgeca_matricula.cod_estado AS estado_matricula,
            REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_1,' ',''),'+51',''),'+','') AS dsc_telefono_1,
            REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_2,' ',''),'+51',''),'+','') AS dsc_telefono_2,
            sgeca_matricula.cod_moneda,
            ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto,0))
                FROM sgede_cronograma_matricula c
                WHERE c.cod_localidad = sgeca_matricula.cod_localidad
                  AND c.num_matricula = sgeca_matricula.num_matricula
                  AND c.num_refinanciamiento = 1), 0) AS INV_NETA,
            (SELECT rhuma_trabajador.dsc_apellido_paterno + ' ' +
                    SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
                    rhuma_trabajador.dsc_nombres
             FROM rhuma_trabajador
             WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor) AS dsc_vendedor,
            sgeca_programa.dsc_programa,
            sgede_RP_programa.dsc_det_programa AS dsc_promocion,
            ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
                WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
                  AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
                  AND sgede_cronograma_matricula.num_refinanciamiento = 1
                  AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
            ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
                WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
                  AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
                  AND sgede_cronograma_matricula.num_refinanciamiento = 1
                  AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1
        FROM sgeca_matricula
        INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
        LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
            AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
            AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
        INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
        WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
          AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000'
          AND sgeca_matricula.cod_estado <> 'ANU'
        """
        conn = self.sql_conn()
        cur = conn.cursor()
        cur.execute(sql)
        cols = [d[0] for d in cur.description]
        rows = cur.fetchall()
        conn.close()
        return [dict(zip(cols, r)) for r in rows]
