/* global window */
// =====================================================================
// Tercer cliente Supabase → Source Investment (windmar-growth-investment).
// Es la FUENTE DE VERDAD para spend (Meta+Google día×ad), leads atribuidos
// con costo + contribución (product margins ya aplicados) y deals.
//
// Aditivo: NO toca el data layer existente. data-supabase.jsx hace overlay
// del header global y rollups con window.si.metrics() cuando está disponible.
// =====================================================================
(function () {
  const URL = window.SI_SUPABASE_URL, KEY = window.SI_SUPABASE_ANON_KEY;
  if (!URL || !KEY || !window.supabase?.createClient) {
    console.warn("[si-data] sin config source-investment");
    window.si = { ready: false };
    return;
  }
  const sisb = window.supabase.createClient(URL, KEY);
  window.__sisb = sisb;

  // PostgREST default max rows per request = 1000. Para totales sumamos en cliente,
  // paginando con .range() hasta agotar.
  async function fetchAllRows(query, pageSize) {
    pageSize = pageSize || 1000;
    let all = [];
    let from = 0;
    for (;;) {
      const { data, error } = await query.range(from, from + pageSize - 1);
      if (error) throw error;
      if (!data || !data.length) break;
      all = all.concat(data);
      if (data.length < pageSize) break;
      from += pageSize;
      if (from > 200000) break; // safety
    }
    return all;
  }

  const sum = (rows, col) => rows.reduce((a, r) => a + Number(r[col] || 0), 0);
  const count = (rows, pred) => rows.reduce((a, r) => a + (pred(r) ? 1 : 0), 0);

  // Set de IDs distintos de una columna (para "¿existe este ad/ad-group?").
  // Paginado con GROUP BY en vez de traer todas las filas — solo nos importan
  // los valores únicos. Mismo patrón que loadIdSet() en
  // windmar-growth-investment/scripts/build-attribution-snapshot.mjs.
  async function fetchIdSet(table, col) {
    const set = new Set();
    let from = 0;
    const pageSize = 1000;
    for (;;) {
      const { data, error } = await sisb.from(table).select(col).not(col, "is", null).range(from, from + pageSize - 1);
      if (error) { console.warn(`[si-data] fetchIdSet(${table}) failed:`, error.message); break; }
      if (!data?.length) break;
      data.forEach((r) => set.add(String(r[col])));
      if (data.length < pageSize) break;
      from += pageSize;
      if (from > 200000) break;
    }
    return set;
  }

  // Reglas portadas 1:1 de windmar-growth-investment/lib/attribution-predicates.ts
  // (mismo repo que hornea /attribution, misma vista v_attribution_leads_costed).
  // Si esas reglas cambian allá, actualizar acá también.
  const CITAS = new Set(["Cita Confirmada", "Cita Realizada", "Cita en Espera"]);
  // Statuses que NO cuentan como "contactado" (lead crudo / basura). Portado 1:1
  // de attribution-predicates.ts (mismo criterio para el funnel).
  const UNCONTACTED = new Set(["", "Nuevo Lead", "Lead Nuevo / Cliente Existente", "Junk Lead"]);
  const round1 = (n) => Math.round(n * 10) / 10;
  const round2 = (n) => Math.round(n * 100) / 100;
  const isSold = (dealId) => !!dealId;
  const isLeadFirst = (r) => r.lead_first === true || r.lead_first === "true" || r.lead_first === "t";
  const isCreditFail = (r) => r.lead_status === "Credit Fail" || r.post_cita_status === "Credit Fail";
  // Disposición final "descartado" (mismo bucket "perdido" de execview.jsx's
  // cro_lead_status mapping) — no confundir con UNCONTACTED (que mide si ya se
  // trabajó el lead, no si terminó descartado).
  const DESCARTADO = new Set(["DQ o No le Interesa", "No Contesta", "Junk Lead", "Lead Frio"]);
  // Cualquier variante de "facebook" se agrupa en "Facebook" — mismo criterio que
  // growth-investment usa para el filtro de Lead Source.
  const normSource = (raw) => { const v = (raw || "(otros)").trim() || "(otros)"; return /facebook/i.test(v) ? "Facebook" : v; };
  // lead_source de CRM suele ser genérico ("Digital") y no dice de qué
  // plataforma vino — para mostrar "de dónde vino" usar platform (ya resuelto
  // por realPlatform() vía channel_info), no lead_source.
  const PLATFORM_LABEL = { google: "Google", meta: "Meta" };

  // -----------------------------------------------------------------
  // metrics(from, to, opts)
  //   Devuelve totales agregados desde Source Investment para [from, to].
  //   opts.accountExtIds : array de act_xxx para filtrar Meta. Google nunca
  //     se filtra por account (no comparte concept) — siempre se incluye.
  //   opts.platform       : "all" (default) | "meta" | "google".
  //   opts.leadSourceIn   : array de lead sources (normalizados) a incluir.
  //     Solo filtra leads/citas/ventas/contribución — el spend NUNCA se filtra
  //     por lead source (es a nivel anuncio, no de lead), igual que growth-investment.
  //   Retorna null si TODA la consulta vuelve vacía (caller hace fallback).
  // -----------------------------------------------------------------
  // Spend Meta — agregado server-side en 2 grupos (por ad y por día) en vez
  // de traer el detalle crudo ad×día (podían ser 30k+ filas paginadas de a
  // 1000, una tras otra — 6+ segundos). Ningún consumidor de _meta/_google
  // cruza ad_id+día a la vez (confirmado leyendo cada uso: topPerformers/
  // productBreakdown/performanceHierarchy/siOverlayCreatives/Campaigns solo
  // necesitan el total por ad; dailySeries solo el total por día) — así que
  // separar en 2 agregados no pierde nada, solo evita traer 30k filas para
  // tirar la mayoría apenas llegan. f_meta_spend_by_ad/by_day nuevas en
  // Source Investment, verificadas 1:1 contra el resultado viejo (0 diff).
  async function fetchMetaSpendRows(platform, from, to, accountExtIds, excludeAccountExtIds) {
    if (platform === "google") return { byAd: [], byDay: [] };
    const [byAd, byDay] = await Promise.all([
      sisb.rpc("f_meta_spend_by_ad", { p_from: from, p_to: to, p_account_ids: accountExtIds?.length ? accountExtIds : null, p_exclude_account_ids: excludeAccountExtIds?.length ? excludeAccountExtIds : null }),
      sisb.rpc("f_meta_spend_by_day", { p_from: from, p_to: to, p_account_ids: accountExtIds?.length ? accountExtIds : null, p_exclude_account_ids: excludeAccountExtIds?.length ? excludeAccountExtIds : null }),
    ]);
    if (byAd.error) throw byAd.error;
    if (byDay.error) throw byDay.error;
    return { byAd: byAd.data || [], byDay: byDay.data || [] };
  }

  // Spend Google (sin filtro de account — los act_ de Meta no aplican; omitido si platform==="meta").
  async function fetchGoogleSpendRows(platform, from, to) {
    if (platform === "meta") return { byAd: [], byDay: [] };
    const [byAd, byDay] = await Promise.all([
      sisb.rpc("f_google_spend_by_campaign", { p_from: from, p_to: to }),
      sisb.rpc("f_google_spend_by_day", { p_from: from, p_to: to }),
    ]);
    if (byAd.error) throw byAd.error;
    if (byDay.error) throw byDay.error;
    return { byAd: byAd.data || [], byDay: byDay.data || [] };
  }

  // Dimensión ad_id → account_id (meta_ads, ~300 filas, sin fecha — el ad
  // pertenece a la cuenta para siempre). Cacheada en memoria. Usarla para el
  // scope de cuenta de los leads en vez de derivar el set de ad_ids desde las
  // filas de spend del rango consultado: eso exigía que el ad hubiera gastado
  // ESE día puntual para contar el lead, y tiraba leads reales de ads con
  // gaps de spend (día sin insight, ad pausado ese día, etc.) — a diferencia
  // de growth-investment, que no ata el conteo de leads al spend del día.
  let _metaAdAccountCache = null;
  async function metaAdAccountMap() {
    if (_metaAdAccountCache) return _metaAdAccountCache;
    try {
      const rows = await fetchAllRows(sisb.from("meta_ads").select("ad_id,account_id"));
      _metaAdAccountCache = new Map(rows.map(r => [String(r.ad_id), r.account_id]));
      return _metaAdAccountCache;
    } catch (e) {
      console.warn("[si-data] metaAdAccountMap failed:", e.message);
      return new Map();
    }
  }

  // Dimensión ad_id → nombre del anuncio (meta_ads.name). Para clasificar por
  // producto los ads que tuvieron SPEND pero cero leads en el rango (esos no
  // aparecen en v_attribution_leads_costed con un ad_name propio).
  let _metaAdNameCache = null;
  async function metaAdNameMap() {
    if (_metaAdNameCache) return _metaAdNameCache;
    try {
      const rows = await fetchAllRows(sisb.from("meta_ads").select("ad_id,name"));
      _metaAdNameCache = new Map(rows.map(r => [String(r.ad_id), r.name]));
      return _metaAdNameCache;
    } catch (e) {
      console.warn("[si-data] metaAdNameMap failed:", e.message);
      return new Map();
    }
  }

  // Dimensión ad_id → campaign_id (meta_ads). Para la jerarquía Producto →
  // Campaña → Creativo: v_attribution_leads_costed no trae campaign_id para
  // Meta (solo el ad_id como entity_id), pero meta_ads sí — join client-side,
  // igual patrón que metaAdAccountMap/metaAdNameMap.
  let _metaAdCampaignCache = null;
  async function metaAdCampaignMap() {
    if (_metaAdCampaignCache) return _metaAdCampaignCache;
    try {
      const rows = await fetchAllRows(sisb.from("meta_ads").select("ad_id,campaign_id"));
      _metaAdCampaignCache = new Map(rows.map(r => [String(r.ad_id), String(r.campaign_id)]));
      return _metaAdCampaignCache;
    } catch (e) {
      console.warn("[si-data] metaAdCampaignMap failed:", e.message);
      return new Map();
    }
  }

  // Dimensión campaign_id → {name, status} (meta_campaigns). status='ACTIVE'
  // es el criterio de "campaña activa" en Meta (distinto string que Google).
  let _metaCampaignInfoCache = null;
  async function metaCampaignInfoMap() {
    if (_metaCampaignInfoCache) return _metaCampaignInfoCache;
    try {
      const rows = await fetchAllRows(sisb.from("meta_campaigns").select("campaign_id,name,status"));
      _metaCampaignInfoCache = new Map(rows.map(r => [String(r.campaign_id), { name: r.name, status: r.status }]));
      return _metaCampaignInfoCache;
    } catch (e) {
      console.warn("[si-data] metaCampaignInfoMap failed:", e.message);
      return new Map();
    }
  }

  // Mismo criterio de clasificación por producto que googleProductForCampaign()
  // (api/_lib.js) — basado en el nombre del anuncio/campaña, no en un campo CRM
  // (deal_pipeline solo existe para ~2% de los leads, product_sold casi vacío).
  function classifyProduct(name) {
    const n = String(name || "").toLowerCase();
    if (n.includes("roofing")) return "roofing";
    if (n.includes("water")) return "water";
    if (n.includes("anker")) return "storage";
    return "solar";
  }

  // act_XXX → producto, para las 4 cuentas de Meta dedicadas por marca
  // (window.DASHBOARDS.general.accounts: Solar/Roofing/Anker/Water — mismas
  // que usa el FilterBar de Cuenta). La cuenta es la fuente de verdad real
  // (WindMar corre cada marca en su propia cuenta de Meta); el nombre del
  // anuncio/campaña es solo un fallback de texto para cuentas mixtas
  // (Consultores/FL/Acción/Reclutamiento, que no son 1 cuenta = 1 producto).
  function metaAccountProductMap() {
    const map = new Map();
    (window.DASHBOARDS?.general?.accounts || []).forEach((a) => {
      const p = String(a.label || "").toLowerCase();
      map.set(a.ext, p === "anker" ? "storage" : p);
    });
    return map;
  }

  // Clasifica un ad de Meta: cuenta dedicada (autoritativa) > nombre (fallback).
  // Root cause del bug donde campañas de Roofing aparecían agrupadas bajo Solar:
  // classifyProduct(adName) se evaluaba sobre el nombre de un ad cualquiera
  // (el primero que llegara en performanceHierarchy, orden no determinístico),
  // no sobre el nombre de la campaña ni la cuenta — un ad sin "roofing" en su
  // propio nombre bastaba para que TODA la campaña cayera en "solar" (default).
  function classifyProductForMeta(name, accountExtId, acctProductMap) {
    if (accountExtId && acctProductMap.has(accountExtId)) return acctProductMap.get(accountExtId);
    return classifyProduct(name);
  }

  // Alias estático → campaign_id real de Google. Hallazgo 2026-07-14: el
  // tracking template de 3 campañas de Search dejó de mandar {campaignid}
  // real y ahora manda un texto fijo (~75% de los leads de Google de los
  // últimos 30 días llegaban con esto, sin poder cruzarse contra
  // google_campaigns → se perdían de performanceHierarchy y se
  // clasificaban mal en productBreakdown). Parche mientras se corrige el
  // tracking template en Google Ads (pendiente, ver conversación con Julian).
  const GOOGLE_CAMPAIGN_ALIASES = {
    "brand-search": "12212043313",   // WH PR - Search - BRAND
    "generic-search": "12462398127", // WH PR - Search - GENERIC
    "roofing-search": "21769392702", // WH PR - Roofing - Search
  };
  function normGoogleCampaignId(raw) {
    const s = String(raw || "");
    return GOOGLE_CAMPAIGN_ALIASES[s] || s;
  }

  // Reclasifica `platform` por channel_info en vez de confiar en el default de
  // v_attribution_leads_costed, que mete CUALQUIER lead sin señal de Google en
  // "meta" — incluye outbound (Estrategia Telemercadeo, Campaña Rescate Solar,
  // Campaña PYMES, Home Depot, etc: miles de leads desde enero 2026). Hallazgo
  // 2026-07-14 (ver conversación con Julian). Devuelve null si no es Meta ni
  // Google — esas filas se descartan, a Julian solo le interesa medir paid
  // media en este dashboard. Cambio 100% client-side: no toca la vista/función
  // compartida en Source Investment, windmar-growth-investment no se entera.
  const META_CHANNELS = new Set(["Facebook Website Leads", "Facebook"]);
  function realPlatform(r) {
    if (r.channel_info === "Google ADS") return "google";
    if (META_CHANNELS.has(r.channel_info)) return "meta";
    return null;
  }

  async function metrics(from, to, opts) {
    opts = opts || {};
    const accountExtIds = Array.isArray(opts.accountExtIds) ? opts.accountExtIds : null;
    const excludeAccountExtIds = Array.isArray(opts.excludeAccountExtIds) ? opts.excludeAccountExtIds : null;
    const platform = opts.platform || "all";
    const leadSourceSet = Array.isArray(opts.leadSourceIn) && opts.leadSourceIn.length ? new Set(opts.leadSourceIn) : null;
    try {
      const [metaRows, gRows] = await Promise.all([
        fetchMetaSpendRows(platform, from, to, accountExtIds, excludeAccountExtIds),
        fetchGoogleSpendRows(platform, from, to),
      ]);

      // Leads + ventas + contribución desde v_attribution_leads_costed.
      // El rango se aplica sobre lead_created (timestamp).
      // OJO: lead_created es TEXT en Postgres ("2026-07-01 00:24:07", con espacio),
      // no timestamptz — PostgREST hace comparación de STRING, no de fecha. Con "T"
      // (ISO) en vez de espacio, "2026-07-01 00:24:07" < "2026-07-01T00:00:00" por
      // orden ASCII (' '=0x20 < 'T'=0x54) → el .gte() del primer día del rango
      // excluía cualquier lead de ese día, sin importar la hora. Con espacio compara bien.
      const fromTs = from + " 00:00:00";
      const toTs   = to   + " 23:59:59";
      // f_attribution_leads_costed(p_from, p_to): misma lógica/columnas que
      // v_attribution_leads_costed, pero filtra por fecha ANTES del cruce
      // (la vista tiene un row_number() que bloquea el pushdown del filtro,
      // forzando un seq scan + sort completo en cada consulta). Ver índice
      // idx_attribution_leads_created + función en el proyecto Source
      // Investment — mismos datos, verificados 1:1 contra la vista, solo
      // más rápido. La vista de windmar-growth-investment no se toca.
      let leadsQ = sisb.rpc("f_attribution_leads_costed", { p_from: fromTs, p_to: toTs })
        .select("lead_created,deal_id,deal_amount,contribution,cost,platform,entity_id,ad_name,lead_status,post_cita_status,lead_first,lead_source,zoho_lead_id,lead_number,link_crm_lead,utm_content,channel_info");
      if (platform !== "all") leadsQ = leadsQ.eq("platform", platform);
      const leadsRowsRaw = await fetchAllRows(leadsQ);
      // Reclasifica + descarta outbound antes de aplicar el resto de los
      // filtros (scope de cuenta, lead source, etc.) — ver realPlatform().
      // Único punto de entrada de metrics(), así que cascada correctamente
      // a funnel/campaignBreakdown/productBreakdown/performanceHierarchy/
      // topPerformers/KPIs.
      const leadsRowsAll = leadsRowsRaw
        .map((r) => ({ ...r, platform: realPlatform(r) }))
        .filter((r) => r.platform);

      // Scope de cuenta: un lead Meta solo cuenta si su ad (entity_id) pertenece a
      // alguna de las cuentas del dashboard — vía la dimensión meta_ads (ad→account
      // fijo), NO vía "¿este ad tuvo spend en este rango?" (ver comentario en
      // metaAdAccountMap). Sin esto, leads/contribución quedaban sin scope mientras
      // el spend sí lo tenía → numerador y denominador de distinta población en
      // cualquier dashboard que no fuera "General" (Consultores/FL/Acción/Reclutamiento).
      // accountExtIds = whitelist (Consultores/FL/Acción/Reclutamiento/drilldown de
      // cuenta); excludeAccountExtIds = blacklist (General: cualquier cuenta de Meta
      // menos las de otro dashboard) — mutuamente excluyentes, ver buildFetchScope.
      const accountSet = accountExtIds?.length ? new Set(accountExtIds) : null;
      const excludeSet = excludeAccountExtIds?.length ? new Set(excludeAccountExtIds) : null;
      const adAccountMap = (accountSet || excludeSet) ? await metaAdAccountMap() : null;
      const inAccountScope = (r) => {
        if (r.platform !== "meta") return true; // Google no se scopea por cuenta (igual que el spend)
        if (accountSet) {
          const acc = adAccountMap.get(String(r.entity_id));
          return !!acc && accountSet.has(acc);
        }
        if (excludeSet) {
          const acc = adAccountMap.get(String(r.entity_id));
          return !acc || !excludeSet.has(acc); // cuenta desconocida → se incluye (no está excluida)
        }
        return true;
      };
      const inLeadSourceScope = (r) => !leadSourceSet || leadSourceSet.has(normSource(r.lead_source));
      const leadsRows = leadsRowsAll.filter(r => inAccountScope(r) && inLeadSourceScope(r));

      // "Lead First" dedupea leads con >1 deal (grano deal → grano lead único).
      // Sin esto, leads/CPL quedaban inflados/deprimidos para leads multi-deal.
      const leadFirstRows = leadsRows.filter(isLeadFirst);

      const metaSpend = sum(metaRows.byAd, "spend");
      const googleSpend = sum(gRows.byAd, "spend");
      const spend = metaSpend + googleSpend;
      const leads = leadFirstRows.length;
      const sold  = count(leadsRows, r => isSold(r.deal_id)); // por deal (grano fila)
      const dealAmount = sum(leadsRows, "deal_amount");
      const contribution = sum(leadsRows, "contribution");
      const leadCost = sum(leadFirstRows, "cost");
      const citas = count(leadFirstRows, r => CITAS.has(r.lead_status) || isSold(r.deal_id));
      const creditFail = count(leadFirstRows, isCreditFail);
      const cac = sold > 0 ? spend / sold : 0;
      const cpl = leads > 0 ? spend / leads : 0;
      const net = contribution - spend;
      const cmPct = contribution > 0 ? (net / contribution) * 100 : 0;

      // Si no hay nada (scope sin data en Source Investment), devolvemos null
      if (!metaRows.byAd.length && !gRows.byAd.length && !leadsRows.length) return null;

      return {
        spend, metaSpend, googleSpend,
        leads, sold, citas, creditFail,
        dealAmount, contribution, leadCost,
        cac, cpl, net, cmPct,
        // raw breakdowns por si los necesitan vistas (e.g. serie diaria) — ya scoped/dedup-aware.
        // _meta/_google: totales por ad_id/campaign_id (spend acumulado del rango).
        // _metaByDay/_googleByDay: totales por día (todo el scope, sin desglose por ad) —
        // usados solo por dailySeries(). Ver fetchMetaSpendRows/fetchGoogleSpendRows.
        _meta: metaRows.byAd,
        _metaByDay: metaRows.byDay,
        _google: gRows.byAd,
        _googleByDay: gRows.byDay,
        _leads: leadsRows,
      };
    } catch (e) {
      console.warn("[si-data] metrics failed:", e.message);
      return null;
    }
  }

  // -----------------------------------------------------------------
  // leadSourceOptions(from, to, opts)
  //   Valores distintos de Lead Source + conteo, sobre el dataset completo del
  //   scope (no solo el período elegido) — para poblar el multi-select, igual
  //   patrón que growth-investment (las opciones no cambian con el período).
  //   f_lead_source_counts(): GROUP BY server-side directo sobre
  //   attribution_leads (sin pasar por la vista lenta, sin paginar 150k+
  //   filas al navegador) — antes tardaba 10+ segundos en cada carga del
  //   dashboard, ahora ~90ms. Verificado 1:1 contra el resultado viejo
  //   (25 grupos, 0 diferencias). Función nueva, no toca la vista compartida.
  // -----------------------------------------------------------------
  async function leadSourceOptions() {
    try {
      const { data, error } = await sisb.rpc("f_lead_source_counts");
      if (error) throw error;
      const counts = new Map();
      (data || []).forEach(r => {
        const src = normSource(r.lead_source);
        counts.set(src, (counts.get(src) || 0) + Number(r.n || 0));
      });
      return Array.from(counts.entries()).map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
    } catch (e) {
      console.warn("[si-data] leadSourceOptions failed:", e.message);
      return [];
    }
  }

  // -----------------------------------------------------------------
  // leadsCosted(from, to)
  //   Lista de leads atribuidos con costo + contribución + status + nombres.
  //   Para drilldowns (CRM list) y leads-by-campaign.
  // -----------------------------------------------------------------
  async function leadsCosted(from, to) {
    try {
      // OJO: lead_created es TEXT en Postgres ("2026-07-01 00:24:07", con espacio),
      // no timestamptz — PostgREST hace comparación de STRING, no de fecha. Con "T"
      // (ISO) en vez de espacio, "2026-07-01 00:24:07" < "2026-07-01T00:00:00" por
      // orden ASCII (' '=0x20 < 'T'=0x54) → el .gte() del primer día del rango
      // excluía cualquier lead de ese día, sin importar la hora. Con espacio compara bien.
      const fromTs = from + " 00:00:00";
      const toTs   = to   + " 23:59:59";
      // f_attribution_leads_costed: ver comentario en metrics() — mismos datos
      // que la vista, filtrando por fecha antes del cruce (usa el índice).
      return await fetchAllRows(
        sisb.rpc("f_attribution_leads_costed", { p_from: fromTs, p_to: toTs })
          .select("*")
          .order("lead_created", { ascending: false })
      );
    } catch (e) {
      console.warn("[si-data] leadsCosted failed:", e.message);
      return null;
    }
  }

  // -----------------------------------------------------------------
  // adRollup(from, to)
  //   Por ad: spend/leads/sold/contribution agregados para el rango.
  //   Útil para overlay en creatives table. Devuelve Map<entity_id, agg>.
  //   NOTA: mv_ad_attribution es por mes; si el rango cae dentro de meses
  //   parciales, el spend incluye días fuera del rango. Si esto es un
  //   problema agregamos desde meta_ads_insights joineando por ad_id.
  // -----------------------------------------------------------------
  async function adRollup(from, to) {
    try {
      // Meses que tocan el rango (YYYY-MM)
      const m1 = from.slice(0, 7), m2 = to.slice(0, 7);
      const rows = await fetchAllRows(
        sisb.from("mv_ad_attribution")
          .select("entity_id,platform,ad_name,month,spend,leads,sold,deal_amount,contribution")
          .gte("month", m1).lte("month", m2)
      );
      const map = new Map();
      rows.forEach(r => {
        const key = r.entity_id;
        const prev = map.get(key) || {
          entity_id: r.entity_id, platform: r.platform, ad_name: r.ad_name,
          spend: 0, leads: 0, sold: 0, deal_amount: 0, contribution: 0,
        };
        prev.spend += Number(r.spend || 0);
        prev.leads += Number(r.leads || 0);
        prev.sold  += Number(r.sold  || 0);
        prev.deal_amount += Number(r.deal_amount || 0);
        prev.contribution += Number(r.contribution || 0);
        map.set(key, prev);
      });
      return map;
    } catch (e) {
      console.warn("[si-data] adRollup failed:", e.message);
      return null;
    }
  }

  // -----------------------------------------------------------------
  // googleCampaignNames()
  //   id → nombre de campaña Google. Sin filtro de fecha (es solo metadata),
  //   cacheado en memoria — para Top Performers (Google se muestra a nivel
  //   campaña, igual que growth-investment, ya que utm_term ahí trae el
  //   keyword, no el ad_id).
  // -----------------------------------------------------------------
  let _googleCampaignNamesCache = null;
  async function googleCampaignNames() {
    if (_googleCampaignNamesCache) return _googleCampaignNamesCache;
    try {
      const rows = await fetchAllRows(sisb.from("google_campaigns").select("campaign_id,name"));
      const map = new Map(rows.map(r => [String(r.campaign_id), r.name]));
      _googleCampaignNamesCache = map;
      return map;
    } catch (e) {
      console.warn("[si-data] googleCampaignNames failed:", e.message);
      return new Map();
    }
  }

  // -----------------------------------------------------------------
  // googleCampaignInfoMap()
  //   id → {name, status} de campaña Google. Como googleCampaignNames() pero
  //   con status ('ENABLED' = activa en Google, distinto string que Meta) —
  //   para la jerarquía Producto → Campaña → Creativo. Función separada para
  //   no tocar googleCampaignNames() (ya usado por topPerformers).
  // -----------------------------------------------------------------
  let _googleCampaignInfoCache = null;
  async function googleCampaignInfoMap() {
    if (_googleCampaignInfoCache) return _googleCampaignInfoCache;
    try {
      const rows = await fetchAllRows(sisb.from("google_campaigns").select("campaign_id,name,status"));
      _googleCampaignInfoCache = new Map(rows.map(r => [String(r.campaign_id), { name: r.name, status: r.status }]));
      return _googleCampaignInfoCache;
    } catch (e) {
      console.warn("[si-data] googleCampaignInfoMap failed:", e.message);
      return new Map();
    }
  }

  // -----------------------------------------------------------------
  // dailySeries(from, to, opts)
  //   Serie diaria spend (Meta+Google) + leads + ventas para gráficos.
  //   Reutiliza los rows que metrics() ya trajo si se pasa `cached`.
  // -----------------------------------------------------------------
  async function dailySeries(from, to, opts) {
    const m = (opts && opts.cached) || (await metrics(from, to, opts));
    if (!m) return null;
    const byDay = {};
    function bump(day, field, val) {
      if (!byDay[day]) byDay[day] = { day, spend: 0, leads: 0, sales: 0, contribution: 0, citas: 0, creditFail: 0 };
      byDay[day][field] += Number(val || 0);
    }
    // Zero-fill de cada día del rango (no solo los que tienen spend/lead) —
    // el run-rate de CmProjectionCard asume un arreglo denso día a día; sin esto,
    // scopes chicos (p.ej. una sola cuenta) pueden devolver 0-1 puntos → NaN.
    const fromNoon = new Date(from + "T12:00:00");
    const dayCount = Math.round((new Date(to + "T12:00:00") - fromNoon) / 86400000) + 1;
    for (let i = 0; i < dayCount; i++) {
      const d = new Date(fromNoon.getTime() + i * 86400000);
      const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
      bump(day, "spend", 0);
    }
    (m._metaByDay || []).forEach(r => bump(String(r.date).slice(0, 10), "spend", r.spend));
    (m._googleByDay || []).forEach(r => bump(String(r.date).slice(0, 10), "spend", r.spend));
    // m._leads ya viene scoped por cuenta/plataforma/lead-source desde metrics();
    // acá solo falta el dedup "lead first" para leads/citas/creditFail (ventas y
    // contribución sí son por deal-row, igual que en metrics()).
    m._leads.forEach(r => {
      const day = String(r.lead_created || "").slice(0, 10);
      if (!day) return;
      if (isSold(r.deal_id)) bump(day, "sales", 1);
      bump(day, "contribution", r.contribution);
      if (!isLeadFirst(r)) return;
      bump(day, "leads", 1);
      if (CITAS.has(r.lead_status) || isSold(r.deal_id)) bump(day, "citas", 1);
      if (isCreditFail(r)) bump(day, "creditFail", 1);
    });
    return Object.keys(byDay).sort().map(k => byDay[k]);
  }

  // -----------------------------------------------------------------
  // funnel(from, to, opts)
  //   Leads → Contactados → Con cita → Vendidos, mismo predicado que el
  //   funnel de windmar-growth-investment/attribution (paridad de número).
  //   Reutiliza los rows que metrics() ya trajo si se pasa opts.cached
  //   (mismo patrón que dailySeries/topPerformers) — no re-consulta.
  //   Devuelve null si metrics() no tuvo datos para el scope (caller hace
  //   fallback a f_funnel/f_funnel_by_source).
  // -----------------------------------------------------------------
  async function funnel(from, to, opts) {
    opts = opts || {};
    const m = opts.cached || (await metrics(from, to, opts));
    if (!m) return null;
    const leadFirstRows = m._leads.filter(isLeadFirst);
    const leads = leadFirstRows.length;
    const contactados = count(leadFirstRows, (r) => !UNCONTACTED.has(r.lead_status || ""));
    const conCita = count(leadFirstRows, (r) => CITAS.has(r.lead_status) || isSold(r.deal_id));
    const vendidos = m.sold; // grano deal, ya calculado por metrics()

    const steps = [
      { key: "leads", metricKey: "leads", label_es: "Leads", label_en: "Leads", count: leads, rate: 100, prev: 0 },
      { key: "contactados", metricKey: "contactados", label_es: "Contactados", label_en: "Contacted", count: contactados, rate: leads > 0 ? round1(contactados / leads * 100) : 0, prev: 0 },
      { key: "cita", metricKey: "cita", label_es: "Con cita", label_en: "With appt.", count: conCita, rate: contactados > 0 ? round1(conCita / contactados * 100) : 0, prev: 0 },
      { key: "sale", metricKey: "sale", label_es: "Vendidos", label_en: "Sold", count: vendidos, rate: conCita > 0 ? round1(vendidos / conCita * 100) : 0, prev: 0 },
    ];

    // bySource: leads/con-cita se cuentan lead-first (evita inflar por multi-deal);
    // vendidos se cuenta grano-deal sobre TODAS las filas de esa fuente.
    const bySourceAgg = new Map();
    leadFirstRows.forEach((r) => {
      const src = normSource(r.lead_source);
      const a = bySourceAgg.get(src) || { source: src, leads: 0, conCita: 0 };
      a.leads++;
      if (CITAS.has(r.lead_status) || isSold(r.deal_id)) a.conCita++;
      bySourceAgg.set(src, a);
    });
    const soldBySource = new Map();
    m._leads.forEach((r) => {
      if (!isSold(r.deal_id)) return;
      const src = normSource(r.lead_source);
      soldBySource.set(src, (soldBySource.get(src) || 0) + 1);
    });
    // Nombres de campo (src/leads/qual/qrate/crate) alineados con buildFunnel()
    // en data-supabase.jsx — FunnelView los consume igual sin importar la fuente.
    const bySource = Array.from(bySourceAgg.values())
      .map((a) => {
        const sold = soldBySource.get(a.source) || 0;
        return {
          src: a.source, leads: a.leads, qual: a.conCita,
          qrate: a.leads > 0 ? round1(a.conCita / a.leads * 100) : 0,
          crate: a.leads > 0 ? round2(sold / a.leads * 100) : 0,
        };
      })
      .sort((a, b) => b.leads - a.leads);

    // statusDistribution: status crudo de CRM (lead-first, para no inflar por multi-deal).
    const statusAgg = new Map();
    leadFirstRows.forEach((r) => {
      const status = (r.lead_status || "").trim() || "(sin status)";
      statusAgg.set(status, (statusAgg.get(status) || 0) + 1);
    });
    const statusDistribution = Array.from(statusAgg.entries())
      .map(([status_raw, cnt]) => ({ status_raw, status_mapped: status_raw, count: cnt, pct: leads > 0 ? round1(cnt / leads * 100) : 0 }))
      .sort((a, b) => b.count - a.count);

    return { steps, bySource, statusDistribution };
  }

  // -----------------------------------------------------------------
  // leadsForMetric(metricKey, opts)
  //   Lista de leads detrás de un número de KPI/funnel (Leads/Contactados/
  //   Con cita/Ventas) — mismo predicado exacto que metrics()/funnel() usan
  //   para contar, así el número del dashboard y el conteo del drilldown
  //   siempre coinciden (a diferencia del drilldown legado, que consultaba
  //   otro modelo — por eso estaba deshabilitado en modo confirmado).
  //   attribution_leads no tiene nombre/email/teléfono (no es una tabla de
  //   contacto) — se identifica por lead_number y se linkea al registro
  //   real en Zoho vía link_crm_lead, que sí tiene el detalle completo.
  // -----------------------------------------------------------------
  // Nombre de campaña para el drilldown: Meta ya trae un ad_name descriptivo
  // usable tal cual; Google en esta vista NO trae nombre de campaña (solo
  // entity_id/utm_campaign, que para las 3 campañas con el bug del ValueTrack
  // es un alias tipo "brand-search" — ver GOOGLE_CAMPAIGN_ALIASES), así que
  // hay que resolverlo contra google_campaigns (mismo mapa que performanceHierarchy).
  async function campaignNameFor(r, gCampInfo) {
    if (r.platform === "google") {
      const cid = normGoogleCampaignId(r.entity_id);
      return gCampInfo.get(cid)?.name || r.entity_id || null;
    }
    return r.ad_name || null;
  }

  async function leadsForMetric(metricKey, opts) {
    const m = opts?.cached || null;
    if (!m) return [];
    const leadFirstRows = m._leads.filter(isLeadFirst);
    let rows;
    switch (metricKey) {
      case "leads":
        rows = leadFirstRows;
        break;
      case "contactados":
        rows = leadFirstRows.filter((r) => !UNCONTACTED.has(r.lead_status || ""));
        break;
      case "cita":
        rows = leadFirstRows.filter((r) => CITAS.has(r.lead_status) || isSold(r.deal_id));
        break;
      case "sale":
      case "sales":
        rows = m._leads.filter((r) => isSold(r.deal_id));
        break;
      case "creditFail":
        rows = leadFirstRows.filter(isCreditFail);
        break;
      default:
        rows = [];
    }
    const gCampInfo = await googleCampaignInfoMap();
    return Promise.all(rows.map(async (r) => {
      let status = "contacted";
      if (isSold(r.deal_id)) status = "won";
      else if (CITAS.has(r.lead_status)) status = "appointment_set";
      else if (UNCONTACTED.has(r.lead_status || "")) status = "new";
      return {
        zoho_id: r.zoho_lead_id,
        lead_number: r.lead_number,
        status,
        status_raw: r.lead_status || "(sin status)",
        source: PLATFORM_LABEL[r.platform] || r.platform || normSource(r.lead_source),
        platform: r.platform,
        campaign_name: await campaignNameFor(r, gCampInfo),
        created_at_zoho: r.lead_created,
        crm_url: r.link_crm_lead || null,
        contribution: Number(r.contribution || 0),
        is_strict_digital: true,
        has_lead_source_digital: true,
      };
    }));
  }

  // -----------------------------------------------------------------
  // campaignBreakdown(from, to, opts)
  //   Leads/Contactados/Con cita/Vendidos por entidad (ad_id Meta,
  //   campaign_id Google — mismo grano que topPerformers/mv_ad_attribution,
  //   NO se rolla el ad de Meta a su campaña padre: la vista no trae esa
  //   dimensión, y windmar-growth-investment tampoco lo hace en su propio
  //   "Top anuncios/campañas"). Reemplaza f_leads_by_campaign (8 buckets
  //   new/working/contacted/qualified/appt_set/appt_held/won/disqualified,
  //   que no tienen equivalente limpio en el status crudo de Source
  //   Investment) por el mismo bucketing de 4 etapas que funnel().
  // -----------------------------------------------------------------
  async function campaignBreakdown(from, to, opts) {
    opts = opts || {};
    const m = opts.cached || (await metrics(from, to, opts));
    if (!m) return null;
    // Google: normaliza alias de tracking template (ver GOOGLE_CAMPAIGN_ALIASES)
    // para no fragmentar una misma campaña en 2 filas (una por id, otra por alias).
    const keyFor = (r) => {
      if (!r.entity_id) return "(sin match)";
      return r.platform === "google" ? normGoogleCampaignId(r.entity_id) : r.entity_id;
    };
    const leadFirstRows = m._leads.filter(isLeadFirst);
    const agg = new Map();
    leadFirstRows.forEach((r) => {
      const key = keyFor(r);
      const a = agg.get(key) || { id: key === "(sin match)" ? null : key, name: r.ad_name || null, platform: r.platform, total: 0, contacted: 0, cita: 0 };
      a.total++;
      if (!UNCONTACTED.has(r.lead_status || "")) a.contacted++;
      if (CITAS.has(r.lead_status) || isSold(r.deal_id)) a.cita++;
      agg.set(key, a);
    });
    const soldByEntity = new Map();
    m._leads.forEach((r) => {
      if (!isSold(r.deal_id)) return;
      const key = keyFor(r);
      soldByEntity.set(key, (soldByEntity.get(key) || 0) + 1);
    });
    return Array.from(agg.values())
      .map((a) => ({ ...a, won: soldByEntity.get(a.id || "(sin match)") || 0 }))
      .sort((a, b) => b.total - a.total);
  }

  // -----------------------------------------------------------------
  // productBreakdown(from, to, opts)
  //   Spend/leads/ventas/contribución por línea de producto (Solar/Roofing/
  //   Storage/Water), clasificado por el anuncio/campaña de origen — no por
  //   deal_pipeline (solo ~2% de los leads tienen deal) ni product_sold (casi
  //   vacío). Reemplaza f_product_rollup (Performance Marketing).
  // -----------------------------------------------------------------
  async function productBreakdown(from, to, opts) {
    opts = opts || {};
    const m = opts.cached || (await metrics(from, to, opts));
    if (!m) return null;
    const [metaNames, gNames, metaAcctMap] = await Promise.all([
      metaAdNameMap(), googleCampaignNames(), metaAdAccountMap(),
    ]);
    const acctProductMap = metaAccountProductMap();
    const nameFor = (platform, entityId, fallback) => {
      if (fallback) return fallback;
      if (!entityId) return null;
      if (platform === "meta") return metaNames.get(String(entityId));
      return gNames.get(normGoogleCampaignId(entityId)); // ver GOOGLE_CAMPAIGN_ALIASES
    };
    // Cuenta dedicada (Solar/Roofing/Anker/Water) > nombre — mismo criterio
    // que performanceHierarchy() (ver classifyProductForMeta).
    const productFor = (platform, entityId, fallback) => {
      const name = nameFor(platform, entityId, fallback);
      if (platform === "meta") return classifyProductForMeta(name, metaAcctMap.get(String(entityId)), acctProductMap);
      return classifyProduct(name);
    };

    const agg = new Map();
    const bump = (product) => {
      if (!agg.has(product)) agg.set(product, { product, leads: 0, sales: 0, contribution: 0, spend: 0 });
      return agg.get(product);
    };
    m._leads.filter(isLeadFirst).forEach((r) => { bump(productFor(r.platform, r.entity_id, r.ad_name)).leads++; });
    m._leads.forEach((r) => {
      if (!isSold(r.deal_id)) return;
      const a = bump(productFor(r.platform, r.entity_id, r.ad_name));
      a.sales++;
      a.contribution += Number(r.contribution || 0);
    });
    m._meta.forEach((r) => { bump(productFor("meta", r.ad_id, null)).spend += Number(r.spend || 0); });
    m._google.forEach((r) => { bump(productFor("google", r.campaign_id, null)).spend += Number(r.spend || 0); });

    const rows = Array.from(agg.values()).map((a) => ({
      id: a.product,
      spend: a.spend, revenue: a.contribution, leads: a.leads, sales: a.sales,
      cac: a.sales > 0 ? a.spend / a.sales : 0,
      cm: a.contribution - a.spend,
      margin: a.contribution > 0 ? round1((a.contribution - a.spend) / a.contribution * 100) : 0,
    }));
    const totalCm = rows.reduce((s, r) => s + r.cm, 0);
    return rows
      .map((r) => ({ ...r, share: totalCm > 0 ? round1(r.cm / totalCm * 100) : 0 }))
      .sort((a, b) => b.spend - a.spend);
  }

  // -----------------------------------------------------------------
  // utmReview(from, to, opts) — "Lead Gen Rev" en windmar-growth-investment.
  //   Leads con id de anuncio SUCIO (no numérico: macro sin renderizar,
  //   click-id, texto, URL-encoded) — Meta usa entity_id (=ad_id, viene de
  //   UTM_Term), Google usa utm_content (=ad_group_id, grano más fino que
  //   entity_id que en Google es el campaign_id). `recoverable` = al limpiar
  //   (URL-decode + solo dígitos) el número corresponde a un anuncio/ad-group
  //   real → corregible en el CRM. Portado 1:1 de buildUtmReview() en
  //   windmar-growth-investment/scripts/build-attribution-snapshot.mjs.
  //   Reemplaza f_leads_no_utms (Performance Marketing).
  // -----------------------------------------------------------------
  const decodeSafe = (raw) => { try { return decodeURIComponent(String(raw ?? "")); } catch { return String(raw ?? ""); } };
  const cleanNum = (raw) => decodeSafe(raw).replace(/\D/g, "");
  const NUM_RE = /^\d+$/;

  function classifyUtmReview(dec, cleaned, recoverable) {
    if (recoverable) return { reason: "recuperable", recommendation: "El id limpio corresponde a un anuncio/ad-group real; corregible en el CRM." };
    if (/\{\{|\}\}|ad\.id|ad\.name|adset|campaign\.id|\{keyword\}|adgroupid|\{[a-z]/i.test(dec)) {
      return { reason: "macro", recommendation: "Plantilla de UTM sin renderizar (p.ej. {{ad.id}}): el id nunca se capturó. No se puede re-atribuir a nivel anuncio; corregir el tracking template en Meta/Google para los próximos leads." };
    }
    if (/^(iwar|paa|afmc|gclid|fbclid)/i.test(dec) || (dec.length >= 24 && /[a-zA-Z]/.test(dec))) {
      return { reason: "click-id", recommendation: "El campo trae un click-id (fbclid/gclid), no un id de anuncio. Revisar el lead y su origen en el CRM." };
    }
    if (cleaned === "") return { reason: "texto", recommendation: "Valor de texto sin números. Verificar manualmente en el CRM de qué anuncio/campaña vino el lead." };
    return { reason: "no-match", recommendation: "Id numérico que no existe en la cuenta actual (anuncio/ad-group borrado o de una cuenta no sincronizada)." };
  }

  async function utmReview(from, to, opts) {
    opts = opts || {};
    const m = opts.cached || (await metrics(from, to, opts));
    if (!m) return null;
    const [metaAdsA, metaAdsB, googleAgA, googleAgB] = await Promise.all([
      fetchIdSet("meta_ads", "ad_id"), fetchIdSet("meta_ads_insights", "ad_id"),
      fetchIdSet("google_ad_groups", "ad_group_id"), fetchIdSet("google_ads_insights", "ad_group_id"),
    ]);
    const metaAds = new Set([...metaAdsA, ...metaAdsB]);
    const adGroups = new Set([...googleAgA, ...googleAgB]);

    const seen = new Set();
    const out = [];
    m._leads.filter(isLeadFirst).forEach((r) => {
      const lead = r.zoho_lead_id;
      if (!lead || seen.has(lead)) return;
      const isG = r.platform === "google";
      const raw = isG ? (r.utm_content || "") : (r.entity_id || "");
      if (!raw) return; // sin id -> nada que limpiar
      // Solo SUCIOS (no-numéricos) — un id ya numérico no se toca, y no
      // podemos validar "existe" con confianza fuera de este chequeo barato.
      if (NUM_RE.test(String(raw))) return;
      seen.add(lead);
      const cleaned = cleanNum(raw);
      const recoverable = cleaned !== "" && (isG ? adGroups.has(cleaned) : metaAds.has(cleaned));
      const { reason, recommendation } = classifyUtmReview(decodeSafe(raw), cleaned, recoverable);
      out.push({
        leadId: lead, leadNumber: r.lead_number || "", platform: r.platform,
        source: r.lead_source || "", field: isG ? "UTM_Content" : "UTM_Term",
        raw: String(raw), cleaned, recoverable, reason, recommendation,
        leadCreated: r.lead_created || "", linkLead: r.link_crm_lead || "",
      });
    });
    out.sort((a, b) => (Number(b.recoverable) - Number(a.recoverable)) || String(b.leadCreated).localeCompare(String(a.leadCreated)));
    const recoverableCount = out.filter((r) => r.recoverable).length;
    return { rows: out, total: out.length, recoverable: recoverableCount, notRecoverable: out.length - recoverableCount };
  }

  // -----------------------------------------------------------------
  // orphanSales(from, to, opts) — "Ventas huérfanas" en windmar-growth-
  //   investment. Deals que NO contamos como venta FB/Google por
  //   associated_lead, pero cuyo teléfono/correo coincide con un lead FB/
  //   Google real y cierran dentro de los 90 días de creado el lead (ya
  //   resuelto server-side, tabla orphan_sales — no requiere account-scope,
  //   igual que la referencia, que tampoco tiene ese concepto). Reemplaza
  //   f_unknown_sales (Performance Marketing) para el bucket "atribuible".
  //   No hay equivalente en SI para "huérfano puro sin match" — ese bucket
  //   solo existe en el modelo legado.
  // -----------------------------------------------------------------
  async function orphanSales(from, to, opts) {
    opts = opts || {};
    const platform = opts.platform || "all";
    const leadSourceSet = Array.isArray(opts.leadSourceIn) && opts.leadSourceIn.length ? new Set(opts.leadSourceIn) : null;
    try {
      const fromTs = from + " 00:00:00";
      const toTs = to + " 23:59:59";
      let q = sisb.from("orphan_sales")
        .select("matched_lead_id,matched_lead_number,lead_source,platform,lead_created,link_crm_lead,orphan_count,phone_count,email_count,contribution,deal_names")
        .gte("lead_created", fromTs).lte("lead_created", toTs);
      if (platform !== "all") q = q.eq("platform", platform);
      const allRows = await fetchAllRows(q);
      const rows = allRows.filter((r) => !leadSourceSet || leadSourceSet.has(normSource(r.lead_source)));

      const bySourceAgg = new Map();
      let totalVentas = 0, totalContribution = 0, phoneCount = 0, emailCount = 0;
      rows.forEach((r) => {
        const src = normSource(r.lead_source);
        const ventas = Number(r.orphan_count || 0);
        const contribution = Number(r.contribution || 0);
        totalVentas += ventas;
        totalContribution += contribution;
        phoneCount += Number(r.phone_count || 0);
        emailCount += Number(r.email_count || 0);
        const a = bySourceAgg.get(src) || { source: src, leads: 0, ventas: 0, contribution: 0 };
        a.leads++; a.ventas += ventas; a.contribution += contribution;
        bySourceAgg.set(src, a);
      });

      return {
        rows: rows.map((r) => ({
          leadId: r.matched_lead_id || "", leadNumber: r.matched_lead_number || "",
          source: r.lead_source || "", platform: r.platform || "",
          leadCreated: r.lead_created || "", linkLead: r.link_crm_lead || "",
          orphanCount: Number(r.orphan_count || 0), phoneCount: Number(r.phone_count || 0), emailCount: Number(r.email_count || 0),
          contribution: Number(r.contribution || 0), dealNames: r.deal_names || "",
        })),
        totalLeads: rows.length, totalVentas, contribution: totalContribution,
        byMatchType: { phone: phoneCount, email: emailCount },
        byLeadSource: Array.from(bySourceAgg.values()).sort((a, b) => b.ventas - a.ventas),
      };
    } catch (e) {
      console.warn("[si-data] orphanSales failed:", e.message);
      return null;
    }
  }

  // -----------------------------------------------------------------
  // performanceHierarchy(from, to, opts)
  //   Producto → Campaña (Meta + Google, solo ACTIVAS) → Creativo (solo
  //   Meta — Google no tiene grano de creativo en esta fuente). Reusa
  //   metrics()'s cached rows (leads, spend Meta, spend Google) — mismo
  //   patrón que funnel/campaignBreakdown/productBreakdown.
  //   Meta: entity_id = ad_id → campaign_id vía metaAdCampaignMap() → nombre/
  //   status vía metaCampaignInfoMap() (status==='ACTIVE').
  //   Google: entity_id YA ES el campaign_id → nombre/status vía
  //   googleCampaignInfoMap() (status==='ENABLED').
  // -----------------------------------------------------------------
  function resolveCampaignRef(r, adCampaignMap, metaCampInfo, gCampInfo) {
    const isG = r.platform === "google";
    if (isG) {
      if (!r.entity_id) return null;
      const campaignId = normGoogleCampaignId(r.entity_id); // ver GOOGLE_CAMPAIGN_ALIASES
      const info = gCampInfo.get(campaignId);
      if (info?.status !== "ENABLED") return null;
      return { campaignId, name: info.name, status: info.status, isG };
    }
    const adId = r.entity_id;
    if (!adId) return null;
    const campaignId = adCampaignMap.get(String(adId));
    if (!campaignId) return null;
    const info = metaCampInfo.get(String(campaignId));
    if (info?.status !== "ACTIVE") return null;
    return { campaignId, name: info.name, status: info.status, isG, adId: String(adId) };
  }

  // Resumen específico de Google Ads para el panel del Overview: gasto (ya
  // scoped al rango vía metrics()) + leads/citas/ventas/descartados reales de
  // CRM, mismos predicados que el resto del dashboard. Síncrono — no hace
  // fetch propio, reusa m ya cacheado en window.__siCurrent.
  function googleSummary(opts) {
    const m = opts?.cached;
    if (!m) return null;
    const rows = m._leads.filter((r) => r.platform === "google");
    const leadFirstRows = rows.filter(isLeadFirst);
    return {
      spend: m.googleSpend,
      leads: leadFirstRows.length,
      citas: count(leadFirstRows, (r) => CITAS.has(r.lead_status) || isSold(r.deal_id)),
      sold: count(rows, (r) => isSold(r.deal_id)),
      descartados: count(leadFirstRows, (r) => DESCARTADO.has(r.lead_status || "")),
    };
  }

  async function performanceHierarchy(from, to, opts) {
    opts = opts || {};
    const m = opts.cached || (await metrics(from, to, opts));
    if (!m) return null;
    const [adCampaignMap, metaCampInfo, gCampInfo, metaAcctMap] = await Promise.all([
      metaAdCampaignMap(), metaCampaignInfoMap(), googleCampaignInfoMap(), metaAdAccountMap(),
    ]);
    const acctProductMap = metaAccountProductMap();

    const campaigns = new Map(); // key = platform|campaignId
    const getCampaign = (ref, adName) => {
      const key = (ref.isG ? "google" : "meta") + "|" + ref.campaignId;
      let c = campaigns.get(key);
      if (!c) {
        const product = ref.isG
          ? classifyProduct(adName || ref.name)
          : classifyProductForMeta(adName || ref.name, metaAcctMap.get(ref.adId), acctProductMap);
        c = {
          id: ref.campaignId, name: ref.name || ref.campaignId, platform: ref.isG ? "google" : "meta", status: ref.status,
          product, spend: 0, leads: 0, contacted: 0, cita: 0, sold: 0, contribution: 0,
          creatives: new Map(),
        };
        campaigns.set(key, c);
      }
      return c;
    };
    const getCreative = (c, adId, adName) => {
      let cr = c.creatives.get(adId);
      if (!cr) { cr = { id: adId, name: adName || adId, spend: 0, leads: 0, cita: 0, sold: 0, contribution: 0 }; c.creatives.set(adId, cr); }
      return cr;
    };

    // Pass 1: leads/contactados/cita (lead-first, evita inflar por multi-deal).
    m._leads.filter(isLeadFirst).forEach((r) => {
      const ref = resolveCampaignRef(r, adCampaignMap, metaCampInfo, gCampInfo);
      if (!ref) return;
      const c = getCampaign(ref, r.ad_name);
      c.leads++;
      if (!UNCONTACTED.has(r.lead_status || "")) c.contacted++;
      const hasCita = CITAS.has(r.lead_status) || isSold(r.deal_id);
      if (hasCita) c.cita++;
      if (!ref.isG) {
        const cr = getCreative(c, ref.adId, r.ad_name);
        cr.leads++;
        if (hasCita) cr.cita++;
      }
    });

    // Pass 2: ventas + contribución (grano deal, TODAS las filas).
    m._leads.forEach((r) => {
      if (!isSold(r.deal_id)) return;
      const ref = resolveCampaignRef(r, adCampaignMap, metaCampInfo, gCampInfo);
      if (!ref) return;
      const c = getCampaign(ref, r.ad_name);
      c.sold++;
      c.contribution += Number(r.contribution || 0);
      if (!ref.isG) {
        const cr = getCreative(c, ref.adId, r.ad_name);
        cr.sold++;
        cr.contribution += Number(r.contribution || 0);
      }
    });

    // Pass 3: spend Meta (por ad, sube a su campaña) + spend Google (por campaña).
    m._meta.forEach((r) => {
      const adId = r.ad_id ? String(r.ad_id) : null;
      if (!adId) return;
      const campaignId = adCampaignMap.get(adId);
      if (!campaignId) return;
      const info = metaCampInfo.get(String(campaignId));
      if (info?.status !== "ACTIVE") return;
      const c = getCampaign({ campaignId, name: info.name, status: info.status, isG: false, adId });
      const spend = Number(r.spend || 0);
      c.spend += spend;
      getCreative(c, adId, null).spend += spend;
    });
    m._google.forEach((r) => {
      const campaignId = r.campaign_id ? String(r.campaign_id) : null;
      if (!campaignId) return;
      const info = gCampInfo.get(campaignId);
      if (info?.status !== "ENABLED") return;
      getCampaign({ campaignId, name: info.name, status: info.status, isG: true }).spend += Number(r.spend || 0);
    });

    // Agrupar campañas por producto.
    const products = new Map();
    campaigns.forEach((c) => {
      c.cm = c.contribution - c.spend;
      c.creativesList = Array.from(c.creatives.values())
        .map((cr) => ({ ...cr, cm: cr.contribution - cr.spend }))
        .sort((a, b) => b.spend - a.spend);
      delete c.creatives;
      const p = products.get(c.product) || { product: c.product, spend: 0, leads: 0, sold: 0, contribution: 0, campaigns: [] };
      p.spend += c.spend; p.leads += c.leads; p.sold += c.sold; p.contribution += c.contribution;
      p.campaigns.push(c);
      products.set(c.product, p);
    });

    return Array.from(products.values())
      .map((p) => ({ ...p, cm: p.contribution - p.spend, campaigns: p.campaigns.sort((a, b) => b.spend - a.spend) }))
      .sort((a, b) => b.spend - a.spend);
  }

  window.si = {
    ready: true,
    sb: sisb,
    metrics,
    leadsCosted,
    leadsForMetric,
    adRollup,
    dailySeries,
    funnel,
    campaignBreakdown,
    productBreakdown,
    utmReview,
    orphanSales,
    performanceHierarchy,
    googleSummary,
    leadSourceOptions,
    normSource,
  };
  console.log("[si-data] ready (Source Investment connected)");
})();
