# backend/services.py
"""Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads."""
from data_manager_v2 import DataManager
import leads_logic as L
from cache_manager import cache_get_or_set

_DM = None


def get_dm() -> DataManager:
    global _DM
    if _DM is None:
        _DM = DataManager()
    return _DM


# ── Datos crudos cacheados (consultas pesadas, una sola vez) ────
def _leads_crudos():
    return cache_get_or_set("leads_raw", ("GLOBAL",),
                            lambda: L.procesar_leads(get_dm().traer_leads_chatwoot()))


def _cursos_crudos():
    return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos())


def _matriculas_crudas():
    return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas())


def _pauta_cruda():
    return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos())


def _programa_pautas():
    """{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos)."""
    return cache_get_or_set("programa_pautas", ("GLOBAL",),
                            lambda: get_dm().traer_programa_pautas())


def _pautas_de_indice():
    """{num_indice: [pautas]} combinando:
    - la pauta de basebi_programacion (1 por num_indice), y
    - las pautas extra de programa_pautas (varias).
    Sin duplicados. Un num_indice puede terminar con varias pautas que se SUMAN."""
    def _load():
        pm = _pauta_cruda()               # {num_indice: {pauta,...}}
        extra = _programa_pautas()        # {num_indice: [pautas]}
        out = {}
        for ni, info in pm.items():
            p = info.get("pauta")
            if p:
                out.setdefault(str(ni).strip(), []).append(str(p).strip())
        for ni, pautas in extra.items():
            lst = out.setdefault(str(ni).strip(), [])
            for p in pautas:
                if str(p).strip() not in lst:
                    lst.append(str(p).strip())
        return out
    return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load)


def _campanias_map():
    return cache_get_or_set("campanias_map", ("GLOBAL",),
                            lambda: get_dm().traer_campanias_map())


def _conjunto_pauta():
    return cache_get_or_set("conjunto_pauta", ("GLOBAL",),
                            lambda: get_dm().traer_conjunto_pauta())


def _meta_filas():
    """Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...]."""
    return cache_get_or_set("meta_filas", ("GLOBAL",),
                            lambda: get_dm().traer_meta_importe())


def _importe_por_pauta():
    """{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los
    conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha."""
    def _load():
        conj_pauta = _conjunto_pauta()                 # {pauta: [conjunto,...]}
        imp_norm = {}
        for f in _meta_filas():
            k = " ".join(str(f.get("conjunto")).split()).upper()
            imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0)
        out = {}
        for pauta, conjuntos in conj_pauta.items():
            total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
            out[str(pauta).strip()] = round(total, 2)
        return out
    return cache_get_or_set("importe_pauta", ("GLOBAL",), _load)


def _importe_por_pauta_periodo(ano, mes, dia):
    """{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe'
    cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba."""
    def _load():
        conj_pauta = _conjunto_pauta()
        imp_norm = {}
        sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
        for f in _meta_filas():
            fx = L._to_date(f.get("inicio"))
            if sin_filtro:
                pass  # sin filtro: cuenta todo (incluye filas sin fecha) = total
            elif not L._en_periodo(fx, ano, mes, dia):
                continue
            k = " ".join(str(f.get("conjunto")).split()).upper()
            imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0)
        out = {}
        for pauta, conjuntos in conj_pauta.items():
            total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
            out[str(pauta).strip()] = round(total, 2)
        return out
    return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load)


def _resultados_por_pauta_periodo(ano, mes, dia):
    """{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe'
    cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados."""
    def _load():
        conj_pauta = _conjunto_pauta()
        res_norm = {}
        sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
        for f in _meta_filas():
            if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
                continue
            k = " ".join(str(f.get("conjunto")).split()).upper()
            res_norm[k] = res_norm.get(k, 0.0) + float(f.get("resultados") or 0.0)
        out = {}
        for pauta, conjuntos in conj_pauta.items():
            total = sum(res_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
            out[str(pauta).strip()] = int(total)
        return out
    return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load)


def _campanias_sede_cargo():
    return cache_get_or_set("campanias_sc", ("GLOBAL",),
                            lambda: get_dm().traer_campanias_sede_cargo())


def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
    """Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice).
    Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados,
    Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor).
    Sede/programa salen de la tabla campanias. Pautas sin campanias NO se cuentan."""
    def _load():
        conj_pauta = _conjunto_pauta()                       # {pauta: [conjuntos]}
        camp = _campanias_sede_cargo()                       # {pauta: {sede,cargo}}
        pauta_map = _pauta_cruda()                           # {num_indice: {pauta,...}}
        con_indice = {str(v.get("pauta")).strip() for v in pauta_map.values() if v.get("pauta")}

        # Leads nuevos (unicos) por pauta = codigo, cuya fecha_creada cae en el mes filtrado
        leads = _leads_crudos()
        leads_nuevos = {}      # codigo -> nº leads unicos del mes
        leads_nuevos_asig = {} # codigo -> nº leads unicos del mes con asesor
        tel_lead_mes = {}      # telefono -> (codigo, fecha_creada) del lead unico del mes
        for l in leads:
            if not l.get("es_unico"):
                continue
            if not L._en_periodo(l.get("fecha"), ano, mes, dia):
                continue
            cod = str(l.get("codigo") or "").strip()
            if not cod:
                continue
            leads_nuevos[cod] = leads_nuevos.get(cod, 0) + 1
            if str(l.get("asesor") or "").strip():
                leads_nuevos_asig[cod] = leads_nuevos_asig.get(cod, 0) + 1
            tel = str(l.get("telefono") or "").strip()
            if tel:
                tel_lead_mes[tel] = (cod, l.get("fecha"))

        # MATRICULAS por pauta: matriculas cuyo telefono = un lead nuevo del mes, y con
        # fch_matricula >= fecha_creada del lead. Cuenta CADA matricula (una vez c/u),
        # atribuida a la pauta del lead. Cruce por telefono (tel_1, o tel_2 si vacio).
        matriculas_pauta = {}  # codigo -> nº matriculas
        venta_pauta = {}       # codigo -> suma INV_NETA en soles (DOL ×3.34)
        _vistas = set()        # (num_matricula) para no contar 2 veces
        for m in _matriculas_crudas():
            t1 = str(m.get("dsc_telefono_1") or "").strip()
            t2 = str(m.get("dsc_telefono_2") or "").strip()
            ph = t1 if t1 else t2
            if ph not in tel_lead_mes:
                continue
            cod, f_lead = tel_lead_mes[ph]
            fm = L._to_date(m.get("fch_matricula"))
            if not fm or not f_lead or fm < f_lead:
                continue                      # matricula anterior al ingreso del lead -> no cuenta
            nm = str(m.get("num_matricula"))
            if nm in _vistas:
                continue
            _vistas.add(nm)
            matriculas_pauta[cod] = matriculas_pauta.get(cod, 0) + 1
            # INV_NETA en soles: si moneda DOL, ×3.34 (igual que tabla por Sede)
            try:
                inv = float(m.get("INV_NETA", 0) or 0)
            except (TypeError, ValueError):
                inv = 0.0
            if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
                inv *= 3.34
            venta_pauta[cod] = venta_pauta.get(cod, 0.0) + inv

        # Importe y Resultados por conjunto, filtrando por el MES de arriba
        imp = {}; res = {}
        sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
        for f in _meta_filas():
            if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
                continue
            k = " ".join(str(f.get("conjunto")).split()).upper()
            imp[k] = imp.get(k, 0.0) + float(f.get("importe") or 0.0)
            res[k] = res.get(k, 0.0) + float(f.get("resultados") or 0.0)

        # Acumular por Sede -> Programa -> Pauta (solo pautas ALWAYS que esten en campanias)
        from collections import defaultdict
        grupos = defaultdict(lambda: defaultdict(lambda: {"importe": 0.0, "resultados": 0.0,
                                                          "nuevos": 0, "nuevos_asig": 0,
                                                          "matriculas": 0, "venta": 0.0, "pautas": []}))
        for pauta, conjuntos in conj_pauta.items():
            p = str(pauta).strip()
            if p in con_indice:            # tiene num_indice -> NO es always
                continue
            info = camp.get(p)
            if not info:                    # no esta en campanias -> no se cuenta
                continue
            se = str(info.get("sede") or "").upper()
            gr = L.grupo_programa_lead(info.get("cargo"))   # TEAC/TERC/SEMINARIOS
            ti = sum(imp.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos)
            tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos)
            if ti == 0 and tr == 0:
                continue                    # sin gasto en el mes -> no aparece
            # filtros de arriba
            if sede not in ("TODOS", None) and se != str(sede).upper():
                continue
            if programa not in ("TODOS", None) and gr != str(programa).upper():
                continue
            nv = leads_nuevos.get(p, 0)
            na = leads_nuevos_asig.get(p, 0)
            mt = matriculas_pauta.get(p, 0)
            vv = venta_pauta.get(p, 0.0)
            g = grupos[se][gr]
            g["importe"] += ti
            g["resultados"] += tr
            g["nuevos"] += nv
            g["nuevos_asig"] += na
            g["matriculas"] += mt
            g["venta"] += vv
            g["pautas"].append({"pauta": p, "importe": round(ti, 2), "resultados": int(tr),
                                 "nuevos": nv, "nuevos_asig": na, "matriculas": mt,
                                 "venta": round(vv, 0)})

        # Armar filas: Sede -> subfilas por programa -> pautas (solo con gasto)
        filas = []
        for se in sorted(grupos.keys()):
            subs = []
            s_imp = s_res = 0.0; s_nv = s_na = s_mt = 0; s_vv = 0.0
            for gr in ("TEAC", "TERC", "SEMINARIOS"):
                if gr in grupos[se]:
                    d = grupos[se][gr]
                    pautas = sorted(d["pautas"], key=lambda x: -x["importe"])
                    subs.append({"programa": gr, "importe": round(d["importe"], 2),
                                 "resultados": int(d["resultados"]),
                                 "nuevos": d["nuevos"], "nuevos_asig": d["nuevos_asig"],
                                 "matriculas": d["matriculas"], "venta": round(d["venta"], 0),
                                 "pautas": pautas})
                    s_imp += d["importe"]; s_res += d["resultados"]
                    s_nv += d["nuevos"]; s_na += d["nuevos_asig"]; s_mt += d["matriculas"]; s_vv += d["venta"]
            filas.append({"sede": se, "importe": round(s_imp, 2), "resultados": int(s_res),
                          "nuevos": s_nv, "nuevos_asig": s_na, "matriculas": s_mt,
                          "venta": round(s_vv, 0), "subfilas": subs})
        tot_i = round(sum(f["importe"] for f in filas), 2)
        tot_r = int(sum(f["resultados"] for f in filas))
        tot_nv = sum(f["nuevos"] for f in filas)
        tot_na = sum(f["nuevos_asig"] for f in filas)
        tot_mt = sum(f["matriculas"] for f in filas)
        tot_vv = round(sum(f["venta"] for f in filas), 0)
        return {"filas": filas, "total": {"importe": tot_i, "resultados": tot_r,
                                          "nuevos": tot_nv, "nuevos_asig": tot_na,
                                          "matriculas": tot_mt, "venta": tot_vv}}

    return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load)


def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
    """Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa.
    Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y
    Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead)."""
    def _load():
        rows = _cartera_rows_cache()
        # 1) leads WEB_FORMULARIO cuya fila es el ORIGEN del telefono en TODA la cartera
        #    (es_origen=SI) y con fecha_creada en el periodo. Asi NO cuenta si el telefono
        #    ya vino antes por otro canal (igual criterio que las demas tablas).
        vistos = {}   # telefono -> {fecha, sede, prog, asesor}
        for r in rows:
            if str(r.get("canal") or "").strip().upper() != "WEB_FORMULARIO":
                continue
            if str(r.get("es_origen") or "").strip().upper() != "SI":
                continue                       # solo el origen real del telefono
            tel = str(r.get("telefono") or "").strip()
            if not tel:
                continue
            f = L._to_date(r.get("fecha_creada"))
            if not L._en_periodo(f, ano, mes, dia):
                continue
            # sede y programa YA vienen normalizados en cartera_junta. Reducir a 4 grupos.
            se = " ".join(str(r.get("sede") or "").upper().split()) or "SIN SEDE"
            pr = " ".join(str(r.get("programa") or "").upper().split())
            if pr == "TEAC":
                gr = "TEAC"
            elif pr == "TERC":
                gr = "TERC"
            elif pr in ("SEMINARIOS", "VRF", "CO2", "DIPLOMADO", "VENTILACIÓN", "VENTILACION"):
                gr = "SEMINARIOS"
            else:
                gr = "OTROS"
            vistos[tel] = {"fecha": f, "sede": se, "prog": gr,
                           "asesor": str(r.get("asesor") or "").strip()}

        # 2) matriculas por telefono (num_matricula, fecha, inv en soles)
        from collections import defaultdict
        mat_por_tel = defaultdict(list)
        for m in _matriculas_crudas():
            t1 = str(m.get("dsc_telefono_1") or "").strip()
            t2 = str(m.get("dsc_telefono_2") or "").strip()
            ph = t1 if t1 else t2
            if not ph:
                continue
            fm = L._to_date(m.get("fch_matricula"))
            try:
                inv = float(m.get("INV_NETA", 0) or 0)
            except (TypeError, ValueError):
                inv = 0.0
            if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
                inv *= 3.34
            mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv))

        # 3) agrupar Sede -> Programa
        grupos = defaultdict(lambda: defaultdict(lambda: {"recibidos": 0, "procesados": 0,
                                                          "matriculas": 0, "venta": 0.0}))
        for tel, info in vistos.items():
            se = info["sede"]; gr = info["prog"]; f_lead = info["fecha"]
            if sede not in ("TODOS", None) and se != str(sede).upper():
                continue
            if programa not in ("TODOS", None) and gr != str(programa).upper():
                continue
            g = grupos[se][gr]
            g["recibidos"] += 1
            if info["asesor"]:
                g["procesados"] += 1
            for (nm, fm, inv) in mat_por_tel.get(tel, []):
                if fm and f_lead and fm >= f_lead:
                    g["matriculas"] += 1
                    g["venta"] += inv

        # 4) filas
        filas = []
        for se in sorted(grupos.keys()):
            subs = []
            s_rec = s_proc = s_mt = 0; s_vv = 0.0
            for gr in ("TEAC", "TERC", "SEMINARIOS", "OTROS"):
                if gr in grupos[se]:
                    d = grupos[se][gr]
                    subs.append({"programa": gr, "recibidos": d["recibidos"],
                                 "procesados": d["procesados"], "matriculas": d["matriculas"],
                                 "venta": round(d["venta"], 0)})
                    s_rec += d["recibidos"]; s_proc += d["procesados"]
                    s_mt += d["matriculas"]; s_vv += d["venta"]
            filas.append({"sede": se, "recibidos": s_rec, "procesados": s_proc,
                          "matriculas": s_mt, "venta": round(s_vv, 0), "subfilas": subs})
        tot = {"recibidos": sum(f["recibidos"] for f in filas),
               "procesados": sum(f["procesados"] for f in filas),
               "matriculas": sum(f["matriculas"] for f in filas),
               "venta": round(sum(f["venta"] for f in filas), 0)}
        return {"filas": filas, "total": tot}

    return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load)


def _leads_asignados_crudos():
    return cache_get_or_set("leads_asignados", ("GLOBAL",),
                            lambda: get_dm().traer_leads_asignados())


def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
    """Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del
    mes; valor = cantidad de telefonos asignados a ese asesor ese dia.
    Fecha = created_at del mensaje de asignacion (ya con -5h)."""
    def _load():
        import calendar
        from collections import defaultdict
        rows = _leads_asignados_crudos()
        # dias del mes filtrado (columnas)
        if mes not in ("TODOS", None) and ano not in ("TODOS", None):
            ndias = calendar.monthrange(int(ano), int(mes))[1]
            dias_cols = list(range(1, ndias + 1))
        else:
            dias_cols = list(range(1, 32))

        # asesores excluidos de esta matriz
        EXCLUIR = {"SIN ASESOR", "COPITO RIVERA"}
        # asesor -> dia -> [telefonos]
        por = defaultdict(lambda: defaultdict(list))
        for r in rows:
            f = L._to_date(r.get("created_at"))
            if not L._en_periodo(f, ano, mes, dia):
                continue
            ase = str(r.get("user_name") or "Sin Asesor").strip() or "Sin Asesor"
            if ase.upper() in EXCLUIR:
                continue
            tel = str(r.get("telefono") or "").strip()
            por[ase][f.day].append(tel)

        filas = []
        tot_por_dia = defaultdict(int)
        for ase in sorted(por.keys()):
            pordia = {d: len(por[ase].get(d, [])) for d in dias_cols}
            total = sum(pordia.values())
            # telefonos por dia (para expandir con +)
            tels_dia = {d: sorted(por[ase].get(d, [])) for d in dias_cols if por[ase].get(d)}
            for d, n in pordia.items():
                tot_por_dia[d] += n
            filas.append({"asesor": ase, "por_dia": pordia, "total": total, "tels_dia": tels_dia})
        filas.sort(key=lambda x: -x["total"])
        total_gral = sum(f["total"] for f in filas)
        return {"dias": dias_cols, "filas": filas,
                "total_por_dia": {d: tot_por_dia.get(d, 0) for d in dias_cols},
                "total": total_gral}
    return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load)


def _plantillas_crudas():
    return cache_get_or_set("plantillas_raw", ("GLOBAL",),
                            lambda: get_dm().traer_plantillas())


def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
    """Matriz de plantillas COBRADAS por plantilla. Columnas:
      ENVIADAS   = Mas_de_24h=SI, created_at_peru en el periodo filtrado
      RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1)
      ASIGNADAS  = de esas, con asesor Y respondida por el contacto
      MATRICULAS = matriculas de esos telefonos (fch_matricula >= envio mas antiguo). Sin filtro de mes.
      VENTA      = INV_NETA (DOL x3.34) de esas matriculas."""
    from datetime import timedelta, datetime
    from collections import defaultdict

    def _load():
        rows = _plantillas_crudas()
        UMBRAL = timedelta(hours=24)

        def _lleno(s):
            return s is not None and str(s).strip() != ""

        enviadas = defaultdict(int)
        respondidas = defaultdict(int)
        asignadas = defaultdict(int)
        prim_por_tel = {}   # telefono -> (fecha_envio, plantilla)  la cobrada mas antigua DEL PERIODO

        for r in rows:
            plantilla = r.get("plantilla")
            envio = r.get("created_at_peru")
            f = L._to_date(envio)
            # ENVIADAS/RESP/ASIG respetan el filtro de mes (por created_at_peru)
            if not L._en_periodo(f, ano, mes, dia):
                continue
            anterior = r.get("hora_anterior_contacto")
            sig = r.get("siguiente_mensaje_1")
            es_si = False
            if plantilla == "descuento_egresados_seminarios":
                es_si = True
            elif anterior is not None and (envio - anterior) > UMBRAL:
                es_si = True
            if not es_si:
                continue
            enviadas[plantilla] += 1
            if _lleno(sig):
                respondidas[plantilla] += 1
            if _lleno(r.get("user_name")) and _lleno(sig):
                asignadas[plantilla] += 1
            tel = str(r.get("telefono") or "").strip()
            if tel and envio is not None:
                if tel not in prim_por_tel or envio < prim_por_tel[tel][0]:
                    prim_por_tel[tel] = (envio, plantilla)

        # MATRICULAS + VENTA de los telefonos filtrados (sin filtro de mes en la matricula)
        mat_por_tel = defaultdict(list)
        for m in _matriculas_crudas():
            t1 = str(m.get("dsc_telefono_1") or "").strip()
            t2 = str(m.get("dsc_telefono_2") or "").strip()
            ph = t1 if t1 else t2
            if not ph:
                continue
            fm = m.get("fch_matricula")
            if isinstance(fm, datetime): fm = fm.date()
            try:
                inv = float(m.get("INV_NETA", 0) or 0)
            except (TypeError, ValueError):
                inv = 0.0
            if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
                inv *= 3.34
            mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv))

        matriculas = defaultdict(int)
        venta = defaultdict(float)
        _vistas = set()
        for tel, (fenvio, plantilla) in prim_por_tel.items():
            fenvio_d = fenvio.date() if hasattr(fenvio, "date") else fenvio
            for (nm, fm, inv) in mat_por_tel.get(tel, []):
                if not fm or nm in _vistas:
                    continue
                if fm >= fenvio_d:
                    _vistas.add(nm)
                    matriculas[plantilla] += 1
                    venta[plantilla] += inv

        filas = []
        for p in sorted(enviadas.keys(), key=lambda x: -enviadas[x]):
            filas.append({"plantilla": p, "enviadas": enviadas[p],
                          "respondidas": respondidas.get(p, 0),
                          "asignadas": asignadas.get(p, 0),
                          "matriculas": matriculas.get(p, 0),
                          "venta": round(venta.get(p, 0.0), 0)})
        tot = {"enviadas": sum(enviadas.values()), "respondidas": sum(respondidas.values()),
               "asignadas": sum(asignadas.values()), "matriculas": sum(matriculas.values()),
               "venta": round(sum(venta.values()), 0)}
        return {"filas": filas, "total": tot}

    return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load)


def _norm_ni(x):
    """Normaliza num_indice a texto (quita el '.0' si viniera como float)."""
    s = str(x or "").strip()
    return s[:-2] if s.endswith(".0") else s


def _mat_por_indice():
    """{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas.
    Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2."""
    def _load():
        from collections import defaultdict
        out = defaultdict(list)
        for m in _matriculas_crudas():
            ni = _norm_ni(m.get("num_indice"))
            t1 = str(m.get("dsc_telefono_1") or "").strip()
            t2 = str(m.get("dsc_telefono_2") or "").strip()
            fm = L._to_date(m.get("fch_matricula"))
            out[ni].append((t1 if t1 else t2, fm))
        return out
    return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load)


def _cartera_rows_cache():
    """Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez).
    Evita re-descargar las 86k filas en cada llamada/cambio de mes."""
    return cache_get_or_set("cartera_rows", ("GLOBAL",),
                            lambda: get_dm().traer_cartera_rows())


def _cartera_idx():
    """Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}.
    'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío).
    Se baja la cartera UNA vez y queda cacheado."""
    def _load():
        from collections import defaultdict
        CANAL_PAUTA = {"COPITO", "PAUTA_WSP", "PAUTA_WSP_FACE"}
        CANAL_WEB = {"WEB_FORMULARIO", "WEB_WHATSAPP", "WHATSAPP WEB"}

        def _clase(canal):
            if canal in CANAL_PAUTA: return "PAUTA"
            if canal in CANAL_WEB:   return "WEB"
            return "OTROS"

        # PASO 1: DEDUP por (sede, prog, telefono) -> quedarse con la fila mas antigua.
        # Se guarda la fecha, si tiene asesor, y el CANAL de esa fila ganadora.
        # (Asi el canal de PAUTA/OTROS se decide DESPUES de deduplicar, como pide el usuario.)
        ganador = {}   # (sede, prog, tel) -> {"f":fecha, "asesor":bool, "canal":canal}
        tels = set()
        tels_canal = {}   # telefono -> canal de su fila ganadora GLOBAL (para matriculas)
        gan_global = {}   # telefono -> fecha ganadora global (para decidir canal de matriculas)
        origen = {}
        for r in _cartera_rows_cache():
            tel = str(r.get("telefono") or "").strip()
            if not tel:
                continue
            tels.add(tel)
            sede = " ".join(str(r.get("sede") or "").upper().split())
            prog = " ".join(str(r.get("programa") or "").upper().split())
            canal = " ".join(str(r.get("canal") or "").upper().split())
            f = L._to_date(r.get("fecha_creada"))
            if str(r.get("es_origen") or "").upper() == "SI" and f is not None:
                origen[tel] = f
            if f is None:
                continue
            con_asesor = bool(str(r.get("asesor") or "").strip())
            # ganador por sede+prog+tel (para la tabla)
            k = (sede, prog, tel)
            g = ganador.get(k)
            if g is None or f < g["f"]:
                ganador[k] = {"f": f, "asesor": con_asesor, "canal": canal}
            # ganador global por tel (para clasificar matriculas por canal)
            if tel not in gan_global or f < gan_global[tel]:
                gan_global[tel] = f
                tels_canal[tel] = canal

        # PASO 2: armar indices con la fila ganadora ya deduplicada
        idx_all = defaultdict(dict); idx_asig = defaultdict(dict)
        # por clase (PAUTA/WEB/OTROS): {clase: {"all":..,"asig":..}}
        CLASES = ("PAUTA", "WEB", "OTROS")
        idx_cl = {cl: {"all": defaultdict(dict), "asig": defaultdict(dict)} for cl in CLASES}
        tels_cl = {cl: set() for cl in CLASES}
        for (sede, prog, tel), g in ganador.items():
            f = g["f"]; cl = _clase(g["canal"])
            idx_all[(sede, prog)][tel] = f
            if g["asesor"]: idx_asig[(sede, prog)][tel] = f
            idx_cl[cl]["all"][(sede, prog)][tel] = f
            if g["asesor"]: idx_cl[cl]["asig"][(sede, prog)][tel] = f
        # telefonos por clase (segun ganador GLOBAL) para matriculas
        for tel, canal in tels_canal.items():
            tels_cl[_clase(canal)].add(tel)

        out = {"all": idx_all, "asig": idx_asig, "tels": tels, "origen": origen}
        for cl in CLASES:
            out[f"all_{cl.lower()}"] = idx_cl[cl]["all"]
            out[f"asig_{cl.lower()}"] = idx_cl[cl]["asig"]
            out[f"tels_{cl.lower()}"] = tels_cl[cl]
        return out
    return cache_get_or_set("cartera_idx", ("GLOBAL",), _load)


# ── Respuesta del dashboard de Leads (con filtros) ──────────────
def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"):
    def _load():
        leads = _leads_crudos()
        cursos = _cursos_crudos()
        mats = _matriculas_crudas()

        # Filtro por PROGRAMA (4 grupos: TEAC / TERC / SEMINARIOS / OTROS).
        # Recalcula toda la pantalla filtrando las listas base ANTES de calcular.
        #  - leads: grupo por 'cargo' del código de campaña
        #  - cursos y matrículas: grupo por dsc_programa
        if programa not in ("TODOS", None):
            g = str(programa).upper()
            leads = [l for l in leads if L.grupo_programa_lead(l.get("cargo")) == g]
            cursos = [c for c in cursos if L.grupo_programa_curso(c.get("dsc_programa")) == g]
            mats = [m for m in mats if L.grupo_programa_curso(m.get("dsc_programa")) == g]

        # Filtro por SEDE (recalcula toda la pantalla). Se filtran las listas base
        # ANTES de calcular, para no tocar las funciones de cálculo existentes.
        #  - leads: sede POR CÓDIGO de campaña (NO sede_act; coincide con conteo real)
        #  - cursos y matrículas: sede por clasificar_sede(dsc_programa)
        if sede not in ("TODOS", None):
            s = str(sede).upper()
            sede_cod = L.sede_por_codigo_map()
            leads = [l for l in leads
                     if sede_cod.get(str(l.get("codigo") or "").strip()) == s]
            cursos = [c for c in cursos if L.clasificar_sede(c.get("dsc_programa")) == s]
            mats = [m for m in mats if L.clasificar_sede(m.get("dsc_programa")) == s]

        kpis = L.kpis_leads(leads, cursos, mats, ano, mes, dia)
        tabla = L.tabla_estado_objecion(leads, ano, mes, dia)
        por_dia = L.matriculas_por_dia(mats, cursos, ano, mes, dia)
        leads_dia = L.leads_por_dia(leads, ano, mes, dia)
        pauta = L.tabla_pauta(leads, mats, ano, mes, dia,
                              _importe_por_pauta_periodo(ano, mes, dia),
                              _resultados_por_pauta_periodo(ano, mes, dia),
                              _campanias_sede_cargo())
        ci = _cartera_idx()
        cartera_canal = {
            "PAUTA": {"all": ci.get("all_pauta"), "asig": ci.get("asig_pauta"), "tels": ci.get("tels_pauta")},
            "WEB":   {"all": ci.get("all_web"),   "asig": ci.get("asig_web"),   "tels": ci.get("tels_web")},
            "OTROS": {"all": ci.get("all_otros"), "asig": ci.get("asig_otros"), "tels": ci.get("tels_otros")},
        }
        matriz = L.matriz_cursos(cursos, leads, ano, mes, dia, _pauta_cruda(),
                                 _campanias_map(), ci.get("all"), ci.get("asig"),
                                 _mat_por_indice(), ci.get("tels"), ci.get("origen"),
                                 _importe_por_pauta(),
                                 _importe_por_pauta_periodo(ano, mes, dia),
                                 cartera_canal, _conjunto_pauta(), _pautas_de_indice())
        return {"kpis": kpis, "estado_objecion": tabla, "matriculas_por_dia": por_dia,
                "leads_por_dia": leads_dia, "tabla_pauta": pauta, "matriz_cursos": matriz}

    return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load)


def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
    """Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta).
    Mas liviano que leads_dashboard -> filtrar por mes es mas rapido."""
    def _load():
        return {
            "matriz_always": matriz_always(ano, mes, dia, "TODOS", "TODOS"),
            "matriz_webform": matriz_web_formulario(ano, mes, dia, "TODOS", "TODOS"),
            "matriz_asignados": matriz_asignados(ano, mes, dia),
            "matriz_plantillas": matriz_plantillas(ano, mes, dia),
        }
    return cache_get_or_set("otros_general", (ano, mes, dia), _load)


def conjuntos_sin_pauta():
    """Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up)."""
    return get_dm().traer_conjuntos_sin_pauta()


def programas_ocultos():
    """Programas con contar=NO en basebi_programacion (ocultos de la matriz).
    Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}."""
    pm = _pauta_cruda()
    ocultos_ni = {ni for ni, info in pm.items() if str(info.get("contar") or "").upper() == "NO"}
    prog_de_ni = {}
    fch_de_ni = {}
    for c in _cursos_crudos():
        ni = _norm_ni(c.get("num_indice"))
        prog_de_ni[ni] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
        f = L._to_date(c.get("fch_inicio"))
        fch_de_ni[ni] = f.strftime("%d/%m/%Y") if f else ""
    filas = []
    for ni in ocultos_ni:
        filas.append({"num_indice": ni,
                      "dsc_det_programa": prog_de_ni.get(ni, "(sin nombre)"),
                      "fch_inicio": fch_de_ni.get(ni, ""),
                      "pauta": (pm.get(ni) or {}).get("pauta") or ""})
    filas.sort(key=lambda x: x["dsc_det_programa"])
    return {"filas": filas}


def encender_programas(num_indices):
    """Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva)."""
    from cache_manager import cache_invalidate
    dm = get_dm()
    for ni in num_indices:
        dm.guardar_pauta_basebi(ni, "", "SI")   # pauta vacia -> no toca pauta, solo contar
    for pref in ("pauta_raw", "leads_dash", "otros_general", "leads_filtros"):
        cache_invalidate(pref)
    return {"encendidos": len(num_indices)}


def programas_disponibles():
    """Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el
    desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}."""
    from datetime import date
    corte = date(2026, 1, 1)
    vistos = {}
    for c in _cursos_crudos():
        f = L._to_date(c.get("fch_inicio"))
        if not f or f < corte:
            continue
        ni = _norm_ni(c.get("num_indice"))
        nombre = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
        if not nombre:
            continue
        finicio = f.strftime("%d/%m/%Y")
        label = f"{nombre} · {finicio}"
        vistos[ni] = {"num_indice": ni, "dsc_det_programa": nombre,
                      "fch_inicio": finicio, "label": label}
    filas = sorted(vistos.values(), key=lambda x: x["label"])
    return {"programas": filas}


def leyenda_anuncios():
    """Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa
    (dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya
    en memoria) para que abrir la Leyenda sea rapido."""
    def _load():
        conj_pauta = _conjunto_pauta()               # cacheado
        pauta_de_conj = {}
        for pa, cjs in conj_pauta.items():
            for c in cjs:
                pauta_de_conj[" ".join(str(c).split())] = str(pa).strip()
        # pauta -> [num_indices] usando TODAS las vinculaciones (basebi + programa_pautas)
        ni_de_pauta = {}
        for ni, pautas in _pautas_de_indice().items():
            for p in pautas:
                ni_de_pauta.setdefault(str(p).strip(), []).append(str(ni).strip())
        prog_de_ni = {}
        for c in _cursos_crudos():                    # cacheado
            prog_de_ni[_norm_ni(c.get("num_indice"))] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
        def _programa(pauta):
            if not pauta:
                return ""
            nis = ni_de_pauta.get(str(pauta).strip(), [])
            nombres = [prog_de_ni.get(n, "") for n in nis if prog_de_ni.get(n)]
            return " | ".join(sorted(set(nombres)))
        filas = []
        vistos = set()
        for f in _meta_filas():                       # cacheado (CSV de Meta)
            co = " ".join(str(f.get("conjunto")).split())
            if not co or co in vistos:
                continue
            vistos.add(co)
            pa = pauta_de_conj.get(co, "")
            filas.append({"conjunto": co, "pauta": pa, "programa": _programa(pa)})
        filas.sort(key=lambda x: x["conjunto"])
        return {"filas": filas}
    return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load)


def guardar_leyenda(cambios):
    """cambios = [{conjunto, pauta, num_indice?}].
    - conjunto -> pauta: upsert en conjunto_pauta.
    - si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion.
    Invalida solo cachés dependientes de pauta/conjunto (no la cartera)."""
    from cache_manager import cache_invalidate
    dm = get_dm()
    for c in cambios:
        conjunto = c.get("conjunto")
        pauta = (c.get("pauta") or "").strip()
        ni = c.get("num_indice")
        if conjunto:
            dm.upsert_conjunto_pauta(conjunto, pauta)
        # si eligió un programa (num_indice), AGREGAR el vínculo num_indice<->pauta
        # en programa_pautas (muchos-a-muchos, se SUMA, no reemplaza).
        if ni and pauta:
            dm.agregar_programa_pauta(str(ni).strip(), pauta)
    for pref in ("conjunto_pauta", "pauta_raw", "programa_pautas", "pautas_de_indice",
                 "meta_filas", "importe_pauta", "importe_pauta_periodo",
                 "resultados_pauta_periodo", "leads_dash", "otros_general",
                 "matriz_always", "matriz_plantillas", "leyenda_anuncios"):
        cache_invalidate(pref)
    return {"guardados": len(cambios)}


def uso_de_pauta(pauta, excluir_num_indice=None):
    """Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa)
    y conjuntos que la usan (para la advertencia antes de guardar)."""
    dm = get_dm()
    nis = dm.num_indices_de_pauta(pauta)
    excl = str(excluir_num_indice or "").strip()
    nis = [n for n in nis if n != excl]
    # dsc_det_programa de esos num_indice (desde cursos de SQL, ya cacheados)
    cursos = _cursos_crudos()
    detalle = []
    for c in cursos:
        ni = _norm_ni(c.get("num_indice"))
        if ni in nis:
            detalle.append({"num_indice": ni,
                            "dsc_det_programa": str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()})
    conjuntos = _conjunto_pauta().get(str(pauta).strip(), [])
    return {"en_uso": bool(nis) or bool(conjuntos),
            "cursos": detalle, "num_indices": nis, "conjuntos": conjuntos}


def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None):
    """Guarda pauta y contar (SI/NO) en basebi_programacion (upsert) y, si se eligió,
    vincula el conjunto a esa pauta en conjunto_pauta. Invalida SOLO los cachés que
    dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida)."""
    from cache_manager import cache_invalidate
    dm = get_dm()
    res = dm.guardar_pauta_basebi(num_indice, pauta, contar)
    if conjunto:
        dm.guardar_conjunto_pauta(conjunto, pauta)
    # invalidar solo lo afectado por pauta/conjunto (la cartera NO se re-baja)
    for pref in ("pauta_raw", "pautas_de_indice", "conjunto_pauta", "meta_filas",
                 "importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo",
                 "leads_dash", "otros_general", "matriz_always", "matriz_plantillas",
                 "leads_filtros"):
        cache_invalidate(pref)
    return res


def _tipo_de_curso(c):
    """Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS)."""
    dscp = c.get("dsc_programa", "")
    otros = L.clasificar_tipo_programa(dscp)
    if otros == "OTROS":
        return "OTROS"
    up = str(dscp).upper()
    cat = "TEAC" if ("AIRE ACONDICIONADO" in up or "TEAC" in up) else (
          "TERC" if ("REFRIGERACION COMERCIAL" in up or "REFRIGERACIÓN COMERCIAL" in up or "TERC" in up) else "SEM")
    sede = L.clasificar_sede(dscp)
    return L.tipo_programa_cohorte(sede, cat)


# ── Listas para los filtros (años, tipos de programa) ───────────
def opciones_filtros():
    def _load():
        cursos = _cursos_crudos()
        anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos
                       if L._to_date(c.get("fch_inicio"))}, reverse=True)
        # Grupos de programa fijos para el filtro PROGRAMA
        tipos = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]
        return {"anos": anos or [2026], "tipos": tipos}
    return cache_get_or_set("leads_filtros", ("GLOBAL",), _load)


# ── Alertas: valores de programa/sede no identificados en cartera_junta ──
def alertas():
    return cache_get_or_set("leads_alertas", ("GLOBAL",),
                            lambda: get_dm().traer_no_identificados())


# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ───────────
def debug_cursos(ano, mes):
    cursos = _cursos_crudos()
    periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")]
    detalle = []
    tot_insc = 0
    tot_meta = 0
    for c in periodo:
        insc = int(c.get("Inscritos_Totales", 0) or 0)
        meta = L.meta_curso(c.get("dsc_programa"))
        tp = L.tipo_programa_curso(c.get("dsc_programa"))
        tot_insc += insc
        tot_meta += meta
        detalle.append({
            "num_indice": c.get("num_indice"),
            "dsc_programa": c.get("dsc_programa"),
            "fch_inicio": str(c.get("fch_inicio"))[:10],
            "cod_estado": c.get("cod_estado"),
            "inscritos": insc,
            "tipo": tp,
            "meta": meta,
        })
    return {
        "num_cursos": len(periodo),
        "suma_inscritos": tot_insc,
        "suma_meta": tot_meta,
        "ocupabilidad": round((tot_insc / tot_meta * 100), 2) if tot_meta else 0,
        "cursos": detalle,
    }


# ── Precarga / refresco ─────────────────────────────────────────
def _periodos_actual_anterior():
    """[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy)."""
    from datetime import date
    hoy = date.today()
    ano, mes = hoy.year, hoy.month
    if mes == 1:
        prev = (ano - 1, 12)
    else:
        prev = (ano, mes - 1)
    return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))]


def precargar_todo():
    """Precarga en segundo plano: insumos crudos + el dashboard del mes actual y
    anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar."""
    try:
        _leads_crudos(); _cursos_crudos(); _matriculas_crudas()
        _leads_asignados_crudos()
        # Dashboard (Leads) del mes actual y anterior (lo mas usado). Otros General
        # NO se precarga aqui (sus consultas son pesadas); se calcula al entrar.
        for (a, m) in _periodos_actual_anterior():
            leads_dashboard(a, m, "TODOS", "TODOS", "TODOS")
            print(f"[precarga] listo {a}-{m}")
        _marcar_actualizacion()
    except Exception as e:
        print(f"[precarga] {e}")


def precargar_otros_general():
    """Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual
    y anterior. Se llama en su propio hilo para no bloquear."""
    try:
        _plantillas_crudas()
        _cartera_rows_cache()   # baja la cartera 1 vez (queda cacheada para todas las matrices)
        for (a, m) in _periodos_actual_anterior():
            otros_general(a, m, "TODOS")
            print(f"[precarga-otros] listo {a}-{m}")
    except Exception as e:
        print(f"[precarga-otros] {e}")


# Hora de la última actualización (hora Perú)
_ULTIMA_ACTUALIZACION = None


def _marcar_actualizacion():
    global _ULTIMA_ACTUALIZACION
    from datetime import datetime, timezone, timedelta
    peru = timezone(timedelta(hours=-5))
    _ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M")


def ultima_actualizacion():
    return {"hora": _ULTIMA_ACTUALIZACION}


def refrescar_todo():
    from cache_manager import cache_invalidate
    cache_invalidate()  # vaciar y recargar TODO
    precargar_todo()            # leads (mes actual/anterior)
    precargar_otros_general()   # cartera + Otros General (para que no quede vacia)
    _marcar_actualizacion()
