// =========================================================================
// REVIN CRM — Supabase client + data loader + login modal
// =========================================================================
// Conecta el front al backend Supabase. Cuando el usuario inicia sesión:
//   1. Pide todos los datos en paralelo
//   2. Transforma del shape DB (snake_case) al shape UI
//   3. Sustituye window.DATA y dispara un re-render
//
// Si el usuario no inicia sesión, sigue funcionando con los mocks de data.js
// =========================================================================

const SUPABASE_URL = "https://vcozoawzvagpojmcokev.supabase.co";
const SUPABASE_KEY = "sb_publishable_BMopJpYBAvjTnu23HKXr_w_HQZ_GQzy";

// ---------- Singleton client -----------------------------------------------
let _client = null;
function getClient() {
  if (_client) return _client;
  if (!window.supabase || !window.supabase.createClient) {
    throw new Error("Supabase JS no cargado. Verifica el script CDN en el HTML.");
  }
  _client = window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY, {
    // `detectSessionInUrl` estaba en false y con eso Google NO puede funcionar:
    // el proveedor devuelve al navegador con un `code` en la URL y, apagado,
    // supabase-js lo ignora — la vuelta de Google acaba en la pantalla de
    // acceso otra vez, sin un solo error, como si no hubieras entrado.
    // `pkce` es el flujo correcto para una app que vive entera en el navegador:
    // el código de un solo uso se canjea contra un verificador que nunca sale
    // de este equipo, así que interceptar la URL de vuelta no basta para entrar.
    auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, flowType: "pkce" },
  });
  return _client;
}

// ---------- Helpers --------------------------------------------------------
const _addrLine = (i) => {
  let s = i.direccion;
  if (i.numero) s += " " + i.numero;
  const flat = (i.planta || "") + (i.puerta ? "º" + i.puerta : "");
  if (flat) s += ", " + flat;
  return s.trim();
};

const _fmtAgo = (ts) => {
  if (!ts) return "—";
  const d = new Date(ts);
  const ms = Date.now() - d.getTime();
  if (ms < 0) return d.toLocaleDateString("es-ES", { day: "2-digit", month: "short" });
  const m = Math.floor(ms / 60000);
  if (m < 60) return `Hace ${m} min`;
  const today = new Date(); today.setHours(0,0,0,0);
  if (d >= today) return "Hoy, " + d.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" });
  const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
  if (d >= yesterday) return "Ayer";
  const days = Math.floor(ms / 86400000);
  if (days < 7) return `Hace ${days} días`;
  return d.toLocaleDateString("es-ES", { day: "2-digit", month: "short" });
};

const _fmtTime = (ts) => new Date(ts).toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" });

// Vencimiento en lenguaje humano relativo a hoy (Hoy 12:00 / Mañana / Ayer / Hace 3 días…).
const _fmtDue = (ts) => {
  if (!ts) return "Sin fecha";
  const d = new Date(ts);
  const startOf = (x) => { const y = new Date(x); y.setHours(0, 0, 0, 0); return y.getTime(); };
  const diff = Math.round((startOf(d) - startOf(new Date())) / 86400000);
  const hhmm = _fmtTime(ts);
  if (diff === 0) return "Hoy " + hhmm;
  if (diff === 1) return "Mañana " + hhmm;
  if (diff === -1) return "Ayer";
  if (diff < 0) return "Hace " + Math.abs(diff) + " días";
  if (diff < 7) return "En " + diff + " días";
  return d.toLocaleDateString("es-ES", { day: "2-digit", month: "short" });
};

const _typeUI = (t) => ({ piso: "PISO", casa: "CASA", local: "LOCAL", nave: "NAVE", garaje: "GARAJE", oficina: "OFICINA", traspaso: "TRASPASO", trastero: "TRASTERO", terreno: "TERRENO", edificio: "EDIFICIO" }[t] || (t ? String(t).toUpperCase() : "—"));

const _clienteTypeUI = (t) => ({
  comprador: "Comprador", vendedor: "Vendedor", inversor: "Inversor",
  propietario: "Propietario", arrendatario: "Arrendatario",
  proveedor: "Proveedor", inmobiliaria: "Inmobiliaria", consultora: "Consultora",
  organismo_publico: "Organismo Público", arquitecto: "Arquitecto",
  gestoria: "Gestoría", notaria: "Notaría", colaborador: "Colaborador", otro: "Otro",
}[t] || t || "Sin tipo");

const _provTypeUI = (t) => ({
  fontaneria: "Fontanería", carpinteria: "Carpintería", cocinas: "Cocinas",
  multigremio: "Multigremio", copisteria: "Copistería", electricidad: "Electricidad",
  albañileria: "Albañilería", pintura: "Pintura", otro: "Otro",
}[t] || t);

const _figuraUI = (f) => ({ particular: "Particular", empresa: "Empresa", autonomo: "Autónomo", otro: "Otro" }[f] || "");
const _motivacionUI = (m) => ({ inversion: "Inversión", vivienda_habitual: "Vivienda habitual", vender: "Vender", otro: "Otro" }[m] || "");
const _estadoUI = (e) => ({
  activo: "Activo", en_riesgo: "En riesgo", cerrado: "Cerrado", perdido: "Perdido",
  en_seguimiento: "En seguimiento", contactado: "Contactado",
  cerrando_acuerdo: "Cerrando acuerdo", llamado: "Llamado", cierre: "Cierre",
}[e] || e);

const _financingUI = (f) => ({
  aprobada: "Aprobada", propia: "Ya la tiene", solicitando: "Solicitando",
  no_solicitada: "No solicitada", rechazada: "Rechazada",
}[f] || "—");

const _originUI = (o) => o ? o[0].toUpperCase() + o.slice(1) : "—";

// Nombre para PINTAR. `clientes.apellidos` está a 0/168 en el legado, así que
// hoy esto devuelve exactamente lo mismo que `nombre` en todas las filas: el
// cambio es invisible hasta el primer alta con apellido. Se usa en la lista,
// la ficha, el ⌘K, móvil, los selectores y el parte de visita — si solo se
// usara en algunos, un contacto nuevo saldría con apellidos en una pantalla y
// sin ellos en la de al lado.
const _nombreCompleto = (nombre, apellidos) =>
  [nombre, apellidos].map(v => (v == null ? "" : String(v).trim())).filter(Boolean).join(" ");

// =========================================================================
// LOAD ALL DATA
// =========================================================================
async function loadDataFromSupabase() {
  const sb = getClient();

  // Antes de pedir un solo dato: comprobar que quien entra está dado de alta y
  // que su cuenta está unida a su ficha. Va AQUÍ porque es el punto único por
  // el que pasan las tres entradas (login, restauración de sesión al abrir, y
  // recarga). Si se pusiera en el login, quien ya tuviera sesión abierta no
  // pasaría nunca por la comprobación.
  const acceso = await asegurarAcceso();
  if (!acceso.ok) throw new Error(acceso.texto);

  const [inmRes, mapRes, cliRes, tarRes, visRes, docRes, comRes, actRes, notRes, pubRes, usrRes, fasRes, proRes, movRes, emiRes, catRes, pbRes, propRes, chkRes] = await Promise.all([
    sb.from("inmuebles").select("*"),
    // El mapa enseña TODA la agencia; el resto de pantallas siguen con la RLS
    // por cartera. Esta funcion devuelve los 56 pero solo con las columnas que
    // el mapa dibuja: sin precio de compra, coste de reforma, balance ni
    // rentabilidad, que es lo que esa politica protege de verdad.
    sb.rpc("inmuebles_para_mapa"),
    // 🔑 `fusionado_en is null` — paso 7 del §5.5 de la spec de Leads. Un contacto
    // absorbido por una fusión NO existe para la UI: sigue en la tabla (nunca se
    // borra, porque 5 FKs son ON DELETE CASCADE y se llevarían su historial y el
    // audit log), pero no es una persona más de la cartera.
    //
    // El filtro va AQUÍ y no en cada pantalla porque `DATA.CLIENTS` y
    // `DATA.COOLING_CLIENTS` salen los dos de esta misma respuesta: filtrando en
    // el origen quedan cubiertos de golpe la lista de Clientes/Leads, los KPIs
    // de Admin ("Contactos" y "enfriándose"), el contador de Móvil, el ⌘K, la
    // ficha y los selectores de cliente. Un filtro por pantalla se olvida en la
    // séptima.
    //
    // NO se consume la vista `clientes_vivos` (que existe y hace lo mismo) a
    // propósito: se creó con `select *`, y eso CONGELA las columnas en el
    // momento de crearla. Ya pasó con `clientes_en_riesgo`, que se quedó en 18
    // columnas mientras `clientes` llegaba a 45. Filtrando aquí, el front nunca
    // se queda sin una columna nueva. La vista es para consumidores SQL.
    sb.from("clientes").select("*").is("fusionado_en", null),
    sb.from("tareas").select("*"),
    sb.from("visitas").select("*"),
    sb.from("documentos").select("*"),
    // MOTOR DE COMISIONES: la fuente ya no es la tabla `comisiones` (deprecada,
    // 0 filas desde la limpieza del seed) sino la vista del motor, que une las
    // previsiones vivas con los movimientos Tipo 2 ya materializados.
    sb.from("proyecto_comisiones_equipo").select("*"),
    sb.from("actividad").select("*").order("created_at", { ascending: false }).limit(20),
    sb.from("notificaciones").select("*").order("created_at", { ascending: false }).limit(20),
    sb.from("publicaciones").select("*"),
    sb.from("usuarios").select("*"),
    sb.from("fases_playbook").select("*").order("orden"),
    sb.from("proyectos").select("*"),
    // Contabilidad
    sb.from("movimientos").select("*").order("fecha", { ascending: false }),
    sb.from("emisores").select("*"),
    sb.from("categorias_movimiento").select("*"),
    sb.from("proyecto_balance").select("*"),   // balances CALCULADOS por proyecto
    // MOTOR DE COMISIONES · el motor PROPONE (base = suma de honorarios Tipo 1
    // de la operación, % = el de la ficha del comercial). La previsión guardada
    // manda sobre esto: la base es un ACUERDO, no una derivada.
    sb.from("comision_equipo_propuesta").select("*"),
    // B2 — CHECKLIST DEL PLAYBOOK, catálogo real (script 28_). Antes salía del
    // mock incluso con sesión, y con `done: true` en varios ítems: cada fase
    // mostraba pasos dados por completados que nadie había completado.
    sb.from("checklist_playbook").select("*").order("fase_key").order("orden"),
  ]);

  const errs = [inmRes, cliRes, tarRes, visRes, docRes, comRes, actRes, notRes, pubRes, usrRes, fasRes, proRes, movRes, emiRes, catRes, pbRes, propRes, chkRes].filter(r => r.error);
  if (errs.length) {
    console.error("Supabase load errors:", errs);
    throw new Error("Errores cargando datos: " + errs.map(r => r.error.message).join(" · "));
  }

  // Lookup maps
  const usuariosById = Object.fromEntries(usrRes.data.map(u => [u.id, u]));
  const fasesById = Object.fromEntries(fasRes.data.map(f => [f.id, f]));
  const clientesById = Object.fromEntries(cliRes.data.map(c => [c.id, c]));
  const inmueblesById = Object.fromEntries(inmRes.data.map(i => [i.id, i]));
  const emisoresById = Object.fromEntries(emiRes.data.map(e => [e.id, e]));
  // Balances CALCULADOS desde la vista (deprecan gastos_totales/balance manuales)
  const proyBalById = Object.fromEntries(pbRes.data.map(b => [b.proyecto_id, b]));

  // Publicaciones agrupadas por inmueble
  const pubsByInm = {};
  pubRes.data.forEach(p => {
    if (!pubsByInm[p.inmueble_id]) pubsByInm[p.inmueble_id] = {};
    const key = p.portal === "web_revin" ? "web" : p.portal;
    pubsByInm[p.inmueble_id][key] = {
      live: p.activo,
      leads: p.leads_acumulados,
      since: p.desde ? new Date(p.desde).toLocaleDateString("es-ES", { day: "2-digit", month: "short" }) : "—",
    };
  });

  // ---------- INMUEBLES ---------------------------------------------------
  const INMUEBLES = inmRes.data.map(i => {
    const lastAct = new Date(i.ultima_actividad_at || i.updated_at || i.created_at);
    const days = Math.floor((Date.now() - lastAct.getTime()) / 86400000);
    const estados = Array.isArray(i.estados) ? i.estados : [];
    const isVendido = estados.includes("Vendido");
    const isDespublicado = estados.includes("Despublicado");
    return {
      id: i.codigo_largo || i.ref || i.id,
      _uid: i.id,
      ref: i.ref,
      // La que tenía antes del renombrado por cajones. Solo para buscar:
      // el equipo lleva años diciendo "el 26014" y tiene que seguir saliendo.
      refAnterior: i.ref_anterior,
      addr: i.direccion || i.nombre_corto || "—",
      nombreCorto: i.nombre_corto,
      town: i.municipio || "—",
      type: _typeUI(i.tipo),
      typeKey: i.tipo,
      area: Number(i.superficie || 0),
      rooms: i.dormitorios || 0,
      baths: i.banos || 0,
      anoConstruccion: i.ano_construccion,
      phase: fasesById[i.fase_id]?.key || "captacion",
      phaseLabel: fasesById[i.fase_id]?.short,
      days: days,
      price: Number(i.precio_venta_actual || 0),
      target: Number(i.precio_venta_objetivo || 0),
      // B2 — Los tres de la calculadora FLIP, SIN coercion a 0: la calculadora
      // necesita distinguir «vale 0 €» de «no hay dato» para dejar el campo
      // vacío en vez de afirmar una cifra. `price`/`target` se quedan como
      // estaban (0) porque los consumen listados y KPIs que ya cuentan con ello.
      precioCompra:  i.precio_compra != null ? Number(i.precio_compra) : null,
      costeReforma:  i.coste_reforma != null ? Number(i.coste_reforma) : null,
      ventaObjetivo: i.precio_venta_objetivo != null ? Number(i.precio_venta_objetivo) : null,
      captador: usuariosById[i.captador_id]?.iniciales || "??",
      comercializador: usuariosById[i.comercializador_id]?.iniciales || "??",
      status: i.estado_texto || estados.join(", ") || "—",
      estados,
      isVendido,
      isDespublicado,
      cooling: days > 14 && !isVendido && !isDespublicado,
      listings: pubsByInm[i.id] || {},
      fechaCompra: i.fecha_compra,
      fechaVenta: i.fecha_venta,
      propietarioNombre: i.propietario_nombre,
      compradorNombre: i.comprador_nombre,
      ubicacionMaps: i.ubicacion_maps,
      // Coordenadas REALES en grados, tal y como vienen de Postgres.
      //
      // Hasta el 27-ago-2026 esto machacaba lat/lng convirtiendolas en
      // porcentajes de CSS (clamp 10-90 alrededor de Jaen) porque el mapa era
      // una maqueta que posicionaba <div> con `left: lng + "%"`. Con datos
      // reales eso apelotonaba toda la cartera en el centro y aplastaba contra
      // el borde cualquier inmueble a mas de 0,2 grados. Ahora el mapa es de
      // verdad y necesita grados, no porcentajes.
      //
      // `null` y no un 50 de relleno: un inmueble sin geocodificar NO se pinta.
      // Inventarle el centro de Jaen es exactamente la clase de mentira que el
      // resto de este front evita.
      lat: i.lat == null ? null : Number(i.lat),
      lng: i.lng == null ? null : Number(i.lng),
      geoConfianza: i.geo_confianza,
      refCatastral: i.ref_catastral,
      refCatastralParcela: i.ref_catastral_parcela,
      catastroDatos: i.catastro_datos,
    };
  });

  // ---------- INMUEBLES DEL MAPA -----------------------------------------
  // Forma minima, a proposito. Si algun dia el mapa necesita un campo mas, se
  // añade AQUI y en la funcion de Postgres, y se ve en el diff que se ha
  // ampliado lo que el equipo entero puede ver.
  // `mapRes` NO entra en la lista de errores fatales de arriba, y es a
  // proposito: si la funcion del mapa falla, el CRM entero debe seguir
  // cargando. Pero degradar en SILENCIO seria enseñar 52 de 56 sin avisar, asi
  // que se marca y el mapa lo dice en pantalla.
  const MAPA_DEGRADADO = !!(mapRes && mapRes.error);
  if (MAPA_DEGRADADO) console.warn("inmuebles_para_mapa() fallo:", mapRes.error);

  const INMUEBLES_MAPA = (mapRes && !mapRes.error && Array.isArray(mapRes.data))
    ? mapRes.data.map(i => ({
        id: i.id,
        _uid: i.id,
        ref: i.ref,
        addr: i.direccion || i.nombre_corto || "—",
        town: i.municipio || "—",
        type: _typeUI(i.tipo),
        typeKey: i.tipo,
        area: Number(i.superficie || 0),
        price: Number(i.precio_venta_actual || 0),
        estados: Array.isArray(i.estados) ? i.estados : [],
        lat: i.lat == null ? null : Number(i.lat),
        lng: i.lng == null ? null : Number(i.lng),
        geoConfianza: i.geo_confianza,
        refCatastral: i.ref_catastral,
        refCatastralParcela: i.ref_catastral_parcela,
        catastroDatos: i.catastro_datos,
      }))
    : null;   // null y no [] : el mapa distingue "no hay vista" de "no hay inmuebles"

  // ---------- CLIENTS -----------------------------------------------------
  const CLIENTS = cliRes.data.map(c => {
    const days = c.entrada_al_pipeline
      ? Math.floor((Date.now() - new Date(c.entrada_al_pipeline).getTime()) / 86400000)
      : c.dias_en_pipeline;
    const lastContactMs = c.ultimo_contacto ? Date.now() - new Date(c.ultimo_contacto).getTime() : null;
    const cooling = c.estado === "en_riesgo" || (lastContactMs && lastContactMs > 14 * 86400000);
    return {
      id: c.id,
      // `name` NO cambia: sigue siendo solo el nombre de pila. Lo leen 17
      // puntos del front y renombrarlo sería un cambio de contrato de verdad.
      // Lo que se PINTA pasa a ser `displayName`.
      name: c.nombre,
      apellidos: c.apellidos || "",
      displayName: _nombreCompleto(c.nombre, c.apellidos),
      // Desambiguador (54_). Va al nivel superior y no dentro de `supabase`
      // porque se pinta junto al nombre y entra en el buscador, como `name`.
      // NO forma parte de `nombre_normalizado`: eso es señal de dedupe.
      alias: c.alias || "",
      phone: c.telefono || "",
      email: c.email || "",
      type: _clienteTypeUI(c.tipo),
      typeKey: c.tipo,
      origin: _originUI(c.origen),
      originKey: c.origen,
      budget: Number(c.presupuesto_min || 0),
      budgetMax: Number(c.presupuesto_max || 0),
      zone: c.zona || "",
      req: c.requisitos || "",
      financing: _financingUI(c.financiacion),
      lastContact: _fmtAgo(c.ultimo_contacto),
      lastContactRaw: c.ultimo_contacto,
      cooling,
      score: 85,
      // Datos completos del contacto
      supabase: {
        id: c.id,
        dni_cif: c.dni_cif,
        direccion: c.direccion,
        cp: c.cp,
        figura_legal: c.figura_legal,
        motivacion: c.motivacion,
        tipo_proveedor: c.tipo_proveedor,
        tipo_inmueble_interes: c.tipo_inmueble_interes,
        estado: c.estado,
        dias_en_pipeline: days,
        entrada_al_pipeline: c.entrada_al_pipeline,
        proximo_seguimiento: c.proximo_seguimiento,
        rrss: c.rrss,
        responsable_raw: c.responsable_raw,
        comercial_asignado: c.comercial_asignado,
        notas_privadas: c.notas_privadas,
        notion_id: c.notion_id,
      },
    };
  });

  // ---------- PHASE_COUNTS ------------------------------------------------
  const PHASE_COUNTS = Object.fromEntries(fasRes.data.map(f => [f.key, 0]));
  inmRes.data.forEach(i => {
    const k = fasesById[i.fase_id]?.key;
    if (k) PHASE_COUNTS[k]++;
  });

  // ---------- COOLING_CLIENTS --------------------------------------------
  const COOLING_CLIENTS = cliRes.data.filter(c => c.estado === "en_riesgo").map(c => {
    const days = c.ultimo_contacto ? Math.floor((Date.now() - new Date(c.ultimo_contacto).getTime()) / 86400000) : 0;
    return {
      id: c.id,
      // Aquí `name` SÍ es el nombre para pintar: este shape no tiene otro campo
      // y solo se usa para mostrar (Dashboard y Móvil).
      name: _nombreCompleto(c.nombre, c.apellidos),
      reason: c.requisitos ? c.requisitos.slice(0, 60) : "Cliente inactivo",
      days,
    };
  });

  // ---------- ACTIVITY ----------------------------------------------------
  const ACTIVITY = actRes.data.map(a => ({
    who: usuariosById[a.usuario_id]?.nombre || "Sistema",
    time: _fmtAgo(a.created_at),
    tone: ({ cambio_fase: "green", visita: "red", llamada: "info", documento: "neutral", captacion: "green", firma: "green", whatsapp: "info" })[a.tipo] || "neutral",
    text: a.descripcion,
  }));

  // ---------- NOTIFICATIONS ----------------------------------------------
  const NOTIFICATIONS = notRes.data.map(n => ({
    id: n.id,
    unread: !n.leida,
    time: _fmtAgo(n.created_at),
    icon: ({ whatsapp: "Whatsapp", visita: "Eye", alerta_frio: "Alert", documento: "Doc", firma: "Check", captacion: "Plus" })[n.tipo] || "Bell",
    tone: ({ whatsapp: "green", visita: "info", alerta_frio: "red", documento: "neutral", firma: "green", captacion: "info" })[n.tipo] || "neutral",
    text: n.texto,
  }));

  // ---------- VISITS ------------------------------------------------------
  const VISITS = visRes.data.map(v => {
    const inm = inmueblesById[v.inmueble_id];
    const cli = clientesById[v.cliente_id];
    return {
      id: v.id,
      inmuebleUid: v.inmueble_id,
      clienteId: v.cliente_id,
      prop: inm ? _addrLine(inm) : "—",
      client: cli?.nombre || "—",
      date: _fmtAgo(v.fecha),
      fechaRaw: v.fecha,
      // Sello de firma: created_at del servidor (NO la fecha de la visita).
      selloFirma: v.created_at,
      // Bloque FIRMADO (inmutable): null en `firma` = visita sin firmar.
      firma: v.firma || null,
      firmada: !!v.firma,
      // Notas comerciales (editables después, aparte de la firma).
      rating: v.valoracion || 0,
      valoracion: v.valoracion,
      text: v.comentario || "",
      comentario: v.comentario || "",
      obj: v.objeciones || [],
      objeciones: v.objeciones || [],
      siguientePaso: v.siguiente_paso || "",
    };
  });

  // ---------- COMMISSIONS (MOTOR DE COMISIONES · Tipo 2) -------------------
  // ADAPTADOR QUE PRESERVA LA FORMA. La fuente cambió (tabla `comisiones` →
  // vista `proyecto_comisiones_equipo`) pero el shape de salida es el mismo, de
  // modo que los 5 consumidores siguen funcionando sin tocarlos:
  // RevinComisiones/FinanceView, Dashboard.jsx, Equipo.jsx (x2) y AdminPortal.
  //
  // UNA FILA POR COMISIÓN, no por inmueble: un mismo inmueble puede generar
  // varias (venta + alquiler + segunda habitación, como Calle Escorial). Agrupar
  // por inmueble fusionaría comisiones distintas y perdería importes.
  //
  // El `rol` decide en qué columna del split cae. Los 14 movimientos históricos
  // son 'responsable' (comercial único de la operación) → van a comercializador;
  // el hueco de captador se marca "—" en vez de inventar un split que no existe.
  const proyectosById = Object.fromEntries(proRes.data.map(p => [p.id, p]));
  const COMMISSIONS = comRes.data.map(c => {
    const inm = c.inmueble_id ? inmueblesById[c.inmueble_id] : null;
    const proy = c.proyecto_id ? proyectosById[c.proyecto_id] : null;
    const importe = Number(c.importe || 0);
    const esCaptador = c.rol === "captador";
    const ini = c.comercial_iniciales || "?";
    return {
      prop: inm ? _addrLine(inm) : (proy?.nombre || "—"),
      ref: inm?.ref || "—",
      captador: esCaptador ? ini : "—",
      comercializador: esCaptador ? "—" : ini,
      capt: esCaptador ? importe : 0,
      com: esCaptador ? 0 : importe,
      total: importe,
      // BASE (honorarios) sustituye a `precio` (precio de venta): el % del
      // comercial se aplica sobre los honorarios que cobra REVIN, no sobre el
      // precio de venta. Null en los históricos, que nacieron como movimiento
      // sin pasar por el motor → la UI pinta "—", no un 0 € engañoso.
      base: c.base_calculo == null ? null : Number(c.base_calculo),
      pct: c.pct_aplicado == null ? null : Number(c.pct_aplicado),
      // Estado en el vocabulario que ya consume FinanceView.
      estado: c.materializada ? "cobrada" : "pendiente",
      // Estado real del motor: prevista | aprobada | pagada | cancelada.
      // Una previsión NO es gasto hasta materializarse (ver CLAUDE.md).
      materializada: !!c.materializada,
      estadoMotor: c.estado || null,
      comercialNombre: c.comercial_nombre || null,
      comercialId: c.comercial_id || null,
      comisionId: c.comision_id || null,
      movimientoId: c.movimiento_id || null,
      fecha: c.fecha || null,
      numeroFactura: null,   // llega en Fase 2 de Contabilidad (facturas)
    };
  });

  // ---------- COMISIONES_EQUIPO (vista cruda, por operación) ---------------
  // COMMISSIONS de arriba es un ADAPTADOR: aplana la vista al shape del split
  // captador/comercializador que consumen 5 pantallas, y en el camino PIERDE
  // proyecto_id/inmueble_id. La ficha de proyecto necesita justo eso, así que
  // se expone la vista casi en crudo en vez de deformar el adaptador (que
  // rompería a sus 5 consumidores).
  const COMISIONES_EQUIPO = comRes.data.map(c => ({
    comisionId: c.comision_id || null,      // null en los 14 históricos: nacieron
    movimientoId: c.movimiento_id || null,  // como movimiento, sin pasar por el motor
    proyectoId: c.proyecto_id || null,
    inmuebleId: c.inmueble_id || null,
    comercialId: c.comercial_id || null,
    comercialNombre: c.comercial_nombre || null,
    comercialIniciales: c.comercial_iniciales || null,
    rol: c.rol || "responsable",
    base: c.base_calculo == null ? null : Number(c.base_calculo),
    baseOrigen: c.base_origen || null,      // 'calculada' | 'manual'
    pct: c.pct_aplicado == null ? null : Number(c.pct_aplicado),
    importe: c.importe == null ? null : Number(c.importe),
    estado: c.estado || null,               // prevista | aprobada | pagada | cancelada
    materializada: !!c.materializada,
    notas: c.notas || null,
    fecha: c.fecha || null,
  }));

  // ---------- PROPUESTAS_COMISION (sugerencia del motor) -------------------
  // El motor propone; el usuario decide. `yaRegistrada` evita ofrecer una
  // previsión sobre una operación que ya tiene comisión — incluidas las
  // HISTÓRICAS de Notion, que antes no miraba: ése fue el bug del doble pago de
  // Murillo 26 (dos gastos de 165 € a Hiurma por la misma comisión de alquiler).
  // `comisionPagada*` dicen CUÁL es, para que la UI pueda nombrarla en vez de
  // limitarse a un "ya hay una".
  const PROPUESTAS_COMISION = (propRes.data || []).map(p => ({
    proyectoId: p.proyecto_id || null,
    inmuebleId: p.inmueble_id || null,
    baseSugerida: p.base_sugerida == null ? null : Number(p.base_sugerida),
    baseMovimientoIds: p.base_movimiento_ids || [],
    comercialId: p.comercial_id || null,
    comercialNombre: p.comercial_nombre || null,
    pctSugerido: p.pct_sugerido == null ? null : Number(p.pct_sugerido),
    importeSugerido: p.importe_sugerido == null ? null : Number(p.importe_sugerido),
    yaRegistrada: !!p.ya_registrada,
    comisionPagadaImporte: p.comision_pagada_importe == null ? null : Number(p.comision_pagada_importe),
    comisionPagadaFecha: p.comision_pagada_fecha || null,
  }));

  // ---------- DOCS_BY_PROPERTY -------------------------------------------
  // (el formateo de tamaño vive en `fmtTamano`, definido más abajo con el
  //  resto de helpers de Storage)
  const DOCS_BY_PROPERTY = {};
  docRes.data.forEach(d => {
    const inm = inmueblesById[d.inmueble_id];
    if (!inm) return;
    const k = inm.codigo_largo || inm.ref;
    if (!DOCS_BY_PROPERTY[k]) DOCS_BY_PROPERTY[k] = [];
    DOCS_BY_PROPERTY[k].push({
      name: d.nombre,
      // `prop` lo consume la tabla global de Documentos. Sin él caía a un
      // literal mock ("Real 24") y TODOS los documentos reales habrían salido
      // con ese inmueble. Se veía tapado por los 4 PDFs falsos.
      prop: inm.direccion || k,
      // `path` = `bucket/clave`. Lo consume `descargarDocumento()`; sin él el
      // botón Descargar no sabe dónde está el fichero.
      path: d.storage_path,
      kind: (d.mime && d.mime.includes("pdf")) ? "PDF" : "FILE",
      // `tipo`, `mime` y `esFoto` los necesita la ficha para separar las FOTOS
      // del resto de documentos y pintarlas. Sin ellos, una foto y una nota
      // simple eran indistinguibles: las dos salian como "FILE".
      tipo: d.tipo,
      mime: d.mime,
      esFoto: d.tipo === "foto" || d.tipo === "foto_portal" ||
              !!(d.mime && d.mime.startsWith("image/")),
      size: fmtTamano(d.tamano_bytes),
      date: new Date(d.created_at).toLocaleDateString("es-ES", { day: "2-digit", month: "short" }),
    });
  });

  // ---------- DOCS_BY_CLIENT ---------------------------------------------
  // Clave = UUID del cliente (`documentos.cliente_id`), no el id de la UI:
  // la ficha ya tiene el uuid a mano en `c.supabase.id` y así no hace falta
  // un mapa intermedio que se pueda desincronizar.
  const DOCS_BY_CLIENT = {};
  docRes.data.forEach(d => {
    if (!d.cliente_id) return;
    if (!DOCS_BY_CLIENT[d.cliente_id]) DOCS_BY_CLIENT[d.cliente_id] = [];
    DOCS_BY_CLIENT[d.cliente_id].push({
      name: d.nombre,
      prop: null,   // cuelga de un cliente, no de un inmueble
      path: d.storage_path,
      kind: (d.mime && d.mime.includes("pdf")) ? "PDF" : "FILE",
      size: fmtTamano(d.tamano_bytes),
      date: new Date(d.created_at).toLocaleDateString("es-ES", { day: "2-digit", month: "short" }),
    });
  });

  // ---------- TODAY_AGENDA: TODAS las tareas pendientes -------------------
  // Todas las pendientes (completada = false), ordenadas por vencimiento asc.
  // (vencidas/atrasadas primero, luego próximas; las sin fecha al final).
  const TODAY_AGENDA = tarRes.data
    .filter(t => !t.completada)
    .sort((a, b) => {
      const av = a.fecha_vencimiento ? new Date(a.fecha_vencimiento).getTime() : Infinity;
      const bv = b.fecha_vencimiento ? new Date(b.fecha_vencimiento).getTime() : Infinity;
      return av - bv;
    })
    .map(t => {
      const inm = inmueblesById[t.inmueble_id];
      const fase = fasesById[t.fase_id];
      return {
        time: t.fecha_vencimiento ? _fmtTime(t.fecha_vencimiento) : "—",
        icon: t.es_obligatoria ? "Doc" : "Calendar",
        tone: t.es_obligatoria ? "red" : "blue",
        title: t.titulo,
        sub: (inm ? _addrLine(inm) : "") + (fase ? " · " + fase.short : ""),
        action: "visit",
      };
    });

  // ---------- TASKS (módulo Tareas) --------------------------------------
  // Todas las tareas, ordenadas: pendientes primero, luego por vencimiento asc.
  const TASKS = tarRes.data
    .slice()
    .sort((a, b) => {
      if (!!a.completada !== !!b.completada) return a.completada ? 1 : -1;
      const av = a.fecha_vencimiento ? new Date(a.fecha_vencimiento).getTime() : Infinity;
      const bv = b.fecha_vencimiento ? new Date(b.fecha_vencimiento).getTime() : Infinity;
      return av - bv;
    })
    .map(t => {
      const inm = inmueblesById[t.inmueble_id];
      const fase = fasesById[t.fase_id];
      return {
        id: t.id,
        title: t.titulo,
        prop: inm?.ref || "—",
        phase: fase?.label || fase?.short || "—",
        due: _fmtDue(t.fecha_vencimiento),
        urgent: !t.completada && !!t.es_obligatoria,
        done: !!t.completada,
      };
    });

  // ---------- TEAM --------------------------------------------------------
  // ⚠️ `estado` va al NIVEL SUPERIOR, no dentro de `supabase`. CLIENTS hace lo
  // contrario (`c.supabase.estado`), y esa asimetría ya costó un bug: Equipo.jsx
  // leía `m.supabase.estado`, obtenía `undefined` —que nunca es "inactivo"— y
  // pintaba a David y Natalia como ACTIVO estando `inactivo` en BD.
  // No se duplica el campo en ambos sitios a propósito: dos fuentes para el mismo
  // dato es cómo se reproduce el bug, en vez de cerrarlo.
  // La columna `usuarios.activo` (booleana, todos true) está DEPRECADA y no se
  // expone aquí: la verdad es `estado`.
  const TEAM = usrRes.data.map(u => ({
    id: u.iniciales,
    name: u.nombre,
    initials: u.iniciales,
    role: ({ admin: "Admin", comercial: "Comercial", captador: "Captadora", cliente: "Cliente" }[u.rol]) || u.rol,
    estado: u.estado,
    supabase: {
      id: u.id,
      email: u.email,
      telefono: u.telefono,
      comision_pct: u.comision_pct,
      objetivo_mensual: u.objetivo_mensual,
      auth_user_id: u.auth_user_id,
      activo_desde: u.activo_desde,
    },
  }));

  // ---------- USER (current) ---------------------------------------------
  // `usuarios` es independiente de auth.users: se vinculan por auth_user_id
  // (o por email vía trigger). Resolver SOLO por u.id === user.id fallaba para
  // cualquier comercial creado por el trigger (bug #11: "usuario actual"→mock).
  const { data: { user } } = await sb.auth.getUser();
  const _uemail = (user?.email || "").toLowerCase();
  const currentUsuario = user
    ? (usrRes.data.find(u => u.auth_user_id === user.id)
       || (_uemail && usrRes.data.find(u => (u.email || "").toLowerCase() === _uemail))
       || usrRes.data.find(u => u.id === user.id))
    : null;
  // `role` es la ETIQUETA que se pinta; `rol` es el dato con el que se decide
  // qué puede ver esta persona. Se exponen los dos a propósito: traducir a
  // "Comercial" para la pantalla y luego volver a comparar contra esa cadena
  // traducida para dar permisos es como se cuelan los fallos de autorización.
  //
  // El caso sin ficha cae en `comercial` — el rol de MENOS privilegio. Si un
  // día no se resuelve el usuario, la app enseña de menos, nunca de más.
  const USER = currentUsuario ? {
    id: currentUsuario.iniciales,
    name: currentUsuario.nombre,
    role: ({ admin: "Admin", comercial: "Comercial", captador: "Captadora" }[currentUsuario.rol]) || currentUsuario.rol,
    rol: currentUsuario.rol,
    initials: currentUsuario.iniciales,
  } : { id: "??", name: "Usuario", role: "Comercial", rol: "comercial", initials: "??" };

  // PHASES en el shape que espera la UI
  const PHASES = fasRes.data.map(f => ({ num: f.num, key: f.key, label: f.label, short: f.short }));

  // CHECKLIST por fase — PLANTILLA, sin estado por inmueble. No se mapea ningún
  // `done`: quién completó qué es la pieza que va con el avance de fase, y hasta
  // que exista, un check pintado sería un paso dado por hecho. Las fases sin
  // ítems se quedan sin entrada y la pantalla lo dice.
  const CHECKLISTS_BY_PHASE = {};
  chkRes.data.forEach(c => {
    (CHECKLISTS_BY_PHASE[c.fase_key] = CHECKLISTS_BY_PHASE[c.fase_key] || [])
      .push({ label: c.label, req: c.obligatorio });
  });

  // ---------- PROYECTOS (servicios a terceros, NO cartera FLIP) -----------
  // Mapa _uid del inmueble -> id de la UI, para poder abrir su ficha.
  const inmUidToUiId = Object.fromEntries(INMUEBLES.map(x => [x._uid, x.id]));
  const num = (v) => (v == null ? null : Number(v));
  const PROYECTOS = proRes.data.map(p => ({
    id: p.id,
    nombre: p.nombre || "—",
    tipo: p.tipo_proyecto || "otro",
    estado: p.estado_proyecto || "otro",
    cliente: p.cliente_nombre || p.responsable_nombre || null,
    presupuesto: num(p.presupuesto_estimado),
    // Gastos y balance CALCULADOS desde la vista proyecto_balance (deprecan los
    // campos manuales gastos_totales/balance heredados de Notion). Si el proyecto
    // aún no tiene movimientos vinculados, se cae al valor manual como fallback.
    gastos: proyBalById[p.id] ? Number(proyBalById[p.id].gastos) : num(p.gastos_totales),
    balance: proyBalById[p.id] ? Number(proyBalById[p.id].balance) : num(p.balance),
    // DEVENGO vs CAJA: `balance` incluye lo comprometido y aún no cobrado
    // (honorarios de intermediación); `balanceCobrado` solo lo efectivamente
    // cobrado/pagado. REVIN cobra honorarios al CIERRE, no antes.
    balanceCobrado: proyBalById[p.id] ? Number(proyBalById[p.id].balance_cobrado) : null,
    ingresosPendientes: proyBalById[p.id] ? Number(proyBalById[p.id].ingresos_pendientes) : 0,
    nMovimientos: proyBalById[p.id] ? Number(proyBalById[p.id].n_movimientos) : 0,
    inmuebleUiId: p.inmueble_id ? (inmUidToUiId[p.inmueble_id] || null) : null,
    inmuebleUid: p.inmueble_id || null,
    notas: p.notas || null,
    checkListDocs: p.check_list_docs || null,
    fechaInicio: p.fecha_inicio,
    fechaFin: p.fecha_fin,
    // NATURALEZA (25_naturaleza.sql): eje independiente de `tipo`. Un proyecto
    // `interno` no devenga comisión de intermediación — la ficha lo dice en
    // neutro, no lo bloquea.
    naturaleza: p.naturaleza || "inmobiliario",
    // Comercial responsable de la operación. Es quien devenga la comisión
    // Tipo 2; NULL no es un error (venta directa sin comercial).
    comercialId: p.comercial_id || null,
    comercialNombre: usuariosById[p.comercial_id]?.nombre || p.comercial_nombre || null,
    comercialIniciales: usuariosById[p.comercial_id]?.iniciales || null,
    comercialPct: usuariosById[p.comercial_id]?.comision_pct == null
      ? null : Number(usuariosById[p.comercial_id].comision_pct),
  }));

  // ---------- MOVIMIENTOS (Contabilidad) ---------------------------------
  // Cada ingreso/gasto. Vínculo a proyecto (la operación) y/o inmueble (activo).
  // CRITERIO emisor (ver CLAUDE.md §4): emisor_id SOLO aplica a INGRESOS (REVIN
  // factura). En un gasto el que factura es el proveedor → emisor no aplica y no
  // se expone en la UI (la limpieza de datos de emisor_id va en una fase aparte).
  const proyRawById = Object.fromEntries(proRes.data.map(p => [p.id, p]));
  const inmUidToLabel = Object.fromEntries(INMUEBLES.map(x => [x._uid, x.addr]));
  const MOVIMIENTOS = movRes.data.map(mv => {
    const proy = mv.proyecto_id ? proyRawById[mv.proyecto_id] : null;
    const hasInm = mv.inmueble_id && inmueblesById[mv.inmueble_id];
    let vinculoTipo = "sin", vinculoLabel = null, proyectoId = null, inmuebleUiId = null;
    if (proy) { vinculoTipo = "proyecto"; vinculoLabel = proy.nombre || "Proyecto"; proyectoId = proy.id; }
    else if (hasInm) { vinculoTipo = "inmueble"; vinculoLabel = inmUidToLabel[mv.inmueble_id] || "Inmueble"; inmuebleUiId = inmUidToUiId[mv.inmueble_id] || null; }
    const esIngreso = mv.tipo === "ingreso";
    return {
      id: mv.id,
      tipo: mv.tipo,
      concepto: mv.concepto || "—",
      fecha: mv.fecha || null,
      categoria: mv.categoria || null,
      estado: mv.estado,
      importe: Number(mv.importe_total || 0),
      valor: Number(mv.valor || 0),
      ivaPct: mv.iva_pct == null ? null : Number(mv.iva_pct),
      metodo: mv.metodo || null,
      pagadoPor: mv.pagado_por || null,
      emisor: esIngreso && mv.emisor_id ? (emisoresById[mv.emisor_id]?.nombre || null) : null,
      proveedorNombre: mv.proveedor_nombre || null,
      clienteNombre: mv.cliente_nombre || null,
      vinculoTipo, vinculoLabel, proyectoId, inmuebleUiId,
      revisar: mv.estado === "revisar",
      notas: mv.notas || null,
      // LOS DOS PLANOS (ver CLAUDE.md §4). 'operacion' = dinero entre TERCEROS
      // o mero dato documental: se muestra, pero NO computa en ningún balance
      // de REVIN. Las vistas SQL ya lo filtran; el front debe filtrarlo también
      // en toda agregación que haga en cliente (ver movBalance en Contabilidad).
      plano: mv.plano || "contable",
      esOperacion: (mv.plano || "contable") === "operacion",
      parteOrigen: mv.parte_origen || null,
      parteDestino: mv.parte_destino || null,
      documentoId: mv.documento_id || null,   // justificante/factura (Storage)
      contrapartidaId: mv.contrapartida_id || null, // par de custodia (netea a 0)
    };
  });
  const EMISORES = emiRes.data.map(e => ({ id: e.id, nombre: e.nombre, tipo: e.tipo }));
  const CATEGORIAS_MOV = catRes.data.map(c => ({ categoria: c.categoria, grupo: c.grupo }));

  // ----- Devolvemos manteniendo lo hardcodeado (analytics y plantillas) ---
  const m = window._MOCK_DATA || {};

  // B0.a — La marca viaja con el DATO. `delMock` devuelve el valor mock Y deja
  // constancia en `marcas`, en la misma expresión: es imposible que un campo salga
  // del mock sin quedar marcado, o que la marca sobreviva al cablearlo de verdad
  // (se borra la llamada y la marca se va con ella).
  // Si NO hay mock disponible, devuelve el vacío y NO marca: un `[]` vacío no es un
  // dato falso, y marcarlo sería mentir en el otro sentido.
  const marcas = {};
  const delMock = (clave, vacio) => {
    const v = m[clave];
    if (v === undefined) return vacio;
    marcas[clave] = true;
    return v;
  };

  return {
    USER, TEAM, PHASES, PHASE_COUNTS, INMUEBLES, INMUEBLES_MAPA, MAPA_DEGRADADO, PROYECTOS, CLIENTS, TASKS,
    TODAY_AGENDA, // en vivo: siempre las tareas reales, sin fallback a mock
    COOLING_CLIENTS, NOTIFICATIONS, ACTIVITY, DOCS_BY_PROPERTY, DOCS_BY_CLIENT, VISITS, COMMISSIONS,
    MOVIMIENTOS, EMISORES, CATEGORIAS_MOV,
    COMISIONES_EQUIPO, PROPUESTAS_COMISION,
    // Mantenemos las analíticas y plantillas hardcodeadas (próxima fase)
    // B2 — CHECKLISTS_BY_PHASE sale ya del catálogo real (arriba) y
    // MATCHES_BY_PROPERTY ha desaparecido: el matching no se puede calcular hoy
    // (faltan precio del inmueble y presupuesto/zona utilizables del cliente),
    // así que la ficha muestra el hueco explicado en vez de candidatos falsos.
    WEEKLY_BARS: delMock("WEEKLY_BARS", []),
    PIPELINE_DONUT: delMock("PIPELINE_DONUT", []),
    CHECKLISTS_BY_PHASE,
    CLIENT_PORTAL_DATA: delMock("CLIENT_PORTAL_DATA", {}),
    // Registro de qué campos de ESTE objeto salen del mock. Lo lee window.RevinMock.
    __mockFields: marcas,
  };
}

// =========================================================================
// AUTH
// =========================================================================
// Entrar con la cuenta de Google Workspace de REVIN.
//
// No hay contraseña que repartir ni que rotar: quien pierde el acceso al correo
// pierde el acceso al CRM, y eso ya se gestiona en un sitio (Workspace) en vez
// de en dos.
//
// ⚠️ Esto NO abre la puerta a cualquiera con una cuenta de Google. Google no
// respeta el alta por invitación: crea la cuenta de auth para quien sea. Quien
// la cierra es `vincular_acceso()`, que corre DESPUÉS y comprueba que ese correo
// tenga ficha en `usuarios`. Sin ficha, se cierra la sesión y se le dice por qué.
async function signInWithGoogle() {
  const sb = getClient();
  // Volver a ESTA página, sin la cadena de búsqueda: si se conserva un `?code`
  // viejo de un intento anterior, el canje falla y el error no dice nada útil.
  const destino = window.location.origin + window.location.pathname;
  const { error } = await sb.auth.signInWithOAuth({
    provider: "google",
    options: { redirectTo: destino },
  });
  // Si no hay error el navegador ya se está yendo a Google; no hay nada que
  // devolver. El resto ocurre al volver, en `arrancarSesion()`.
  if (error) throw error;
}

async function signInEmailPassword(email, password) {
  const sb = getClient();
  const { data, error } = await sb.auth.signInWithPassword({ email, password });
  if (error) throw error;
  return data;
}

async function signOut() {
  const sb = getClient();
  await sb.auth.signOut();
}

// -------------------------------------------------------------------------
// ALTA POR INVITACIÓN — la presentación entre la cuenta y la ficha
// -------------------------------------------------------------------------
// Una persona existe DOS veces: en `auth.users` (la cuenta con la que entra,
// que crea Google) y en `usuarios` (su ficha de empleado). Las une la columna
// `usuarios.auth_user_id`, y NADIE las une solo.
//
// El orden es el problema: a Rafa se le da de alta ANTES de que entre, así que
// su ficha nace sin cuenta. Cuando entra con Google, Supabase le crea una
// cuenta NUEVA que no está conectada a su ficha. Resultado: tiene sesión, pero
// para la base es un desconocido — `is_staff()` es false, el token no lleva
// `org_id`, y la RLS (que es RESTRICTIVE por organización) le vacía TODAS las
// pantallas sin un solo error. Un CRM entero en blanco y ni una pista.
//
// `public.vincular_acceso()` hace esa presentación: mira el correo VERIFICADO
// por el proveedor, busca la ficha con ese correo, y si existe y sirve, escribe
// el enlace y crea la membership. No acepta parámetros a propósito — el correo
// lo saca de `auth.users`, no de quien llama, así que nadie puede reclamar la
// ficha de otro.
//
// Y quien no tenga ficha NO se queda dentro: se cierra la sesión y se dice por
// qué. Google no respeta el alta por invitación; esto es lo que la sostiene.
const MOTIVO_ACCESO = {
  no_dado_de_alta: "Tu correo no está dado de alta en REVIN. El alta es solo por invitación: pídesela a Carlos.",
  inactivo: "Tu cuenta está desactivada. Habla con Carlos.",
  sin_permiso: "Tu ficha no es de equipo. El CRM es solo para el equipo de REVIN.",
  ocupada: "Ese correo ya está reclamado por otra cuenta de acceso. Avisa a Carlos: hay que resolverlo a mano.",
  correo_sin_verificar: "El proveedor no ha confirmado que ese correo sea tuyo, así que no se vincula nada.",
  sin_rol: "Tu ficha tiene un rol que no existe en la tabla de roles. Avisa a Carlos.",
  sin_correo: "La cuenta con la que has entrado no trae correo. Prueba con otra.",
};

// Una vez por sesión y por usuario: es una escritura, no hace falta repetirla
// en cada recarga de datos.
let _accesoOk = { uid: null };

async function asegurarAcceso() {
  const sb = getClient();
  const { data: { session } } = await sb.auth.getSession();
  const user = session && session.user;

  // Sin sesión no hay nada que vincular: el modo mock es un estado legítimo.
  if (!user) return { ok: true };
  if (_accesoOk.uid === user.id) return { ok: true };

  const { data: veredicto, error } = await sb.rpc("vincular_acceso");

  // Si la función no estuviera desplegada, no se bloquea la entrada: se avisa
  // por consola y se sigue como antes. Degradar es mejor que dejar fuera a
  // todo el mundo por un despliegue a medias.
  if (error) {
    console.warn("[REVIN] vincular_acceso() no disponible:", error.message);
    _accesoOk = { uid: user.id };
    return { ok: true };
  }

  if (veredicto === "vinculado") {
    // El `org_id` viaja en los CLAIMS del token, y este token se emitió ANTES
    // de que existiera la membership. Sin refrescar, la RLS sigue viendo una
    // sesión sin organización y las pantallas salen vacías igual.
    await sb.auth.refreshSession();
    _orgCache = { uid: null, org: null };
  }

  if (veredicto === "ok" || veredicto === "vinculado") {
    _accesoOk = { uid: user.id };
    return { ok: true };
  }

  await sb.auth.signOut();
  _accesoOk = { uid: null };
  return { ok: false, motivo: veredicto, texto: MOTIVO_ACCESO[veredicto] || String(veredicto) };
}

// =========================================================================
// ORG (Fase 0 · multi-tenant) — inyección CENTRALIZADA de org_id
// =========================================================================
// Un ÚNICO punto (`conOrg`) mete el org_id en todo INSERT del front. No un
// parche por formulario: cada helper de alta envuelve su payload con `conOrg`.
//
// El org_id se resuelve desde la SESIÓN, nunca hardcodeado. Fuentes en orden:
//   1) claim del JWT `app_metadata.org_id` — lo inyectará el hook del Paso C;
//      en cuanto exista se usa sin ninguna consulta extra.
//   2) `memberships` del usuario (fuente de hoy; legible por la política
//      "memberships: propia o admin"). `es_predeterminada` primero por si el
//      día de mañana un usuario pertenece a varias orgs.
// Si no hay sesión / no se resuelve, devuelve null y el INSERT cae al DEFAULT
// del Paso B en la BD (red de seguridad, no se rompe el alta).
let _orgCache = { uid: null, org: null };
async function resolverOrgId() {
  const sb = getClient();
  const { data: { session } } = await sb.auth.getSession();   // local, sin red
  const user = session?.user;
  if (!user) return null;
  if (_orgCache.uid === user.id && _orgCache.org) return _orgCache.org;   // cache por usuario
  const claim = user.app_metadata && user.app_metadata.org_id;
  if (claim) { _orgCache = { uid: user.id, org: claim }; return claim; }
  const { data, error } = await sb.from("memberships")
    .select("org_id")
    .eq("auth_user_id", user.id)
    .eq("estado", "activa")
    .order("es_predeterminada", { ascending: false })
    .limit(1).maybeSingle();
  if (error) { console.warn("[REVIN] org_id no resuelto desde la sesión:", error.message); return null; }
  const org = (data && data.org_id) || null;
  _orgCache = { uid: user.id, org };
  return org;
}
// Envuelve un payload de INSERT con el org_id de la sesión. No pisa un org_id
// explícito y es no-op si no hay org resoluble (queda al DEFAULT del Paso B).
async function conOrg(payload) {
  const org = await resolverOrgId();
  return (org && (payload == null || payload.org_id == null)) ? { ...payload, org_id: org } : payload;
}

// Crea una tarea real en Supabase. RLS: INSERT permitido a staff (is_staff()).
// created_by/asignado_a se resuelven al usuario logueado (independiente de auth.users).
async function crearTarea({ titulo, inmueble_id = null, fecha_vencimiento = null } = {}) {
  const sb = getClient();
  const { data: { user } } = await sb.auth.getUser();
  let usuarioId = null;
  if (user) {
    const { data: u } = await sb.from("usuarios")
      .select("id")
      .or(`auth_user_id.eq.${user.id},email.eq.${user.email}`)
      .limit(1).maybeSingle();
    usuarioId = u?.id || null;
  }
  const { data, error } = await sb.from("tareas").insert(await conOrg({
    titulo,
    inmueble_id: inmueble_id || null,
    fecha_vencimiento: fecha_vencimiento || null,
    es_obligatoria: false,
    completada: false,
    asignado_a: usuarioId,
    created_by: usuarioId,
  })).select().single();
  if (error) throw error;
  return data;
}

// Alta de contacto (cliente/lead O proveedor — misma tabla, distinto `tipo`).
// Mismo patrón que `crearTarea`: valida, inyecta org_id vía `conOrg` y persiste.
// `tipo` y `estado` tienen DEFAULT en BD (comprador / activo), así que un alta
// mínima solo exige el nombre. `tipo_proveedor` (especialidad) solo aplica a los
// proveedores; se omite del payload si no llega, para no ensuciar a un cliente.
// 🔑 NOMBRE Y APELLIDOS son obligatorios en un alta nueva. Sin apellido, el
// matching blando por nombre es ruido: en la base hay «José», «Miguel»,
// «Marta», «Fernando» y CUATRO «Juanma».
//
// EXCEPCIÓN · `identidad_incompleta`. Los leads del setter entran sin apellidos
// porque ManyChat solo da el nombre de pila —y a veces ni eso: entre las 53
// conversaciones reales hay un `{{first_name}}` sin renderizar—. Esos NO se
// bloquean: perder un lead por un apellido sería absurdo. Nacen marcados para
// poder listarlos y completarlos al llamar, y la marca se limpia sola (trigger
// del 53_) en cuanto alguien rellena el apellido.
//
// Los 168 contactos de legado no se tocan: la guarda de la BD es un trigger
// BEFORE INSERT, así que solo alcanza a las filas nuevas.
// =========================================================================
// ERRORES CON CARA HUMANA
// =========================================================================
// Un error de Postgres es exacto y no sirve de nada a quien está delante:
// «permission denied for function crear_cliente_con_identidades» no le dice a
// un comercial qué hacer. Medido en producción el 11/08: el mensaje que veía el
// usuario al fallar un alta era «Falta el apellido de "X"… créalo con
// identidad_incompleta = true», una instrucción para un programador en un
// formulario que ni siquiera tenía ese campo.
//
// El técnico NO se pierde: va íntegro a consola, que es donde se depura.
// Devuelve un Error nuevo, para lanzarlo tal cual.
function errorAmable(err, accion = "completar la operación") {
  console.error(`[REVIN] fallo al ${accion}:`, err);
  const code = String(err?.code || "");
  const msg  = String(err?.message || "");

  // Sin red: supabase-js lo envuelve en un TypeError de fetch.
  if (/failed to fetch|networkerror|load failed/i.test(msg)) {
    return new Error("Sin conexión con el servidor. Comprueba tu conexión e inténtalo otra vez; no se ha guardado nada.");
  }
  // La función no existe para este rol: front más nuevo que la base, o falta el grant.
  if (code === "PGRST202" || /could not find the function/i.test(msg)) {
    return new Error("El servidor no reconoce esta operación. Recarga la página; si sigue igual, avisa a Carlos: falta desplegar una parte.");
  }
  if (code === "42501" || /permission denied|row-level security/i.test(msg)) {
    return new Error("No tienes permiso para hacer esto. Si crees que deberías tenerlo, habla con un administrador.");
  }
  if (code === "23514" && /apellido/i.test(msg)) {
    return new Error("Los apellidos son obligatorios para dar de alta un contacto.");
  }
  if (code === "23514") {
    return new Error("Faltan datos obligatorios o hay una combinación que no se permite. Revisa el formulario.");
  }
  if (code === "23505") {
    return new Error("Ya existe un registro con ese dato. Búscalo antes de crearlo de nuevo.");
  }
  if (code === "23503") {
    return new Error("Uno de los datos apunta a algo que ya no existe. Recarga la página e inténtalo otra vez.");
  }
  if (code === "22004" || code === "22023") {
    return new Error("Faltan datos obligatorios o alguno no es válido. Revisa el formulario.");
  }
  // Los `raise exception` propios ya están escritos en castellano y para humanos.
  if (/^[A-ZÁÉÍÓÚÑ¿¡]/.test(msg) && msg.length < 200 && !/[_]{1}[a-z]+[_(]/.test(msg)) {
    return new Error(msg);
  }
  return new Error(`No se ha podido ${accion}. El detalle técnico está en la consola del navegador.`);
}

// CHEQUEO DE DUPLICADOS (56_). La búsqueda vive en SQL, no aquí: la
// normalización (norm_telefono / norm_email / norm_dni) está en la base, y
// reimplementarla en JS sería garantizar que las dos versiones se separen — el
// día que lo hicieran, el chequeo dejaría de encontrar duplicados en silencio.
//
// Devuelve candidatos con `confianza`: dura (DNI validado) · media (teléfono,
// email) · blanda (nombre). NO decide nada: decide la UI, y en MEDIA una persona.
async function buscarDuplicados({ telefono, email, dni, nombre, apellidos, excluir } = {}) {
  const sb = getClient();
  const { data, error } = await sb.rpc("buscar_duplicados", {
    p_telefono:  telefono  || null,
    p_email:     email     || null,
    p_dni:       dni       || null,
    p_nombre:    nombre    || null,
    p_apellidos: apellidos || null,
    p_excluir:   excluir   || null,
  });
  if (error) throw error;
  return data || [];
}

// El usuario de `usuarios` detrás de la sesión, para atribuir decisiones.
// Mismo patrón que firmarVisita().
async function usuarioActualId() {
  const sb = getClient();
  const { data: { user } } = await sb.auth.getUser();
  if (!user) return null;
  const { data: u } = await sb.from("usuarios").select("id")
    .or(`auth_user_id.eq.${user.id},email.eq.${user.email}`).limit(1).maybeSingle();
  return u?.id || null;
}

// 🔑 NOMBRE Y APELLIDOS obligatorios; excepción `identidad_incompleta` para el
// setter (ver 53_).
//
// Va por RPC y no por INSERT directo porque el alta tiene que crear TAMBIÉN las
// identidades del contacto, en la misma transacción. Sin eso, un contacto nuevo
// no existe para `buscar_duplicados` y el chequeo nace ciego ante todo lo que se
// dé de alta a partir de hoy — comprobado el 11/08: ningún trigger las genera.
//
// `distinto_de`: contactos sobre los que el usuario YA dijo «es otra persona».
// La función marca compartidas las identidades que chocan y registra el par en
// `contacto_no_duplicados` para que el aviso no vuelva.
async function crearCliente({ nombre, apellidos = null, tipo = "comprador", telefono = null,
                              email = null, zona = null, tipo_proveedor = null, alias = null,
                              dni_cif = null, identidad_incompleta = false,
                              distinto_de = [] } = {}) {
  const sb = getClient();
  const nom = (nombre || "").trim();
  const ape = (apellidos || "").trim();
  if (!nom) throw new Error("El nombre es obligatorio.");
  if (!ape && !identidad_incompleta) {
    throw new Error("Los apellidos son obligatorios. Si el contacto viene del setter y solo tienes el nombre de pila, márcalo como identidad incompleta.");
  }
  const { data, error } = await sb.rpc("crear_cliente_con_identidades", {
    p_nombre: nom,
    p_apellidos: ape || null,
    p_tipo: tipo,
    p_telefono: telefono || null,
    p_email: email || null,
    p_zona: zona || null,
    p_tipo_proveedor: tipo_proveedor || null,
    p_alias: alias || null,
    p_dni_cif: dni_cif || null,
    p_identidad_incompleta: !!(identidad_incompleta && !ape),
    p_distinto_de: distinto_de || [],
    p_usuario_id: await usuarioActualId(),
  });
  if (error) throw errorAmable(error, "crear el contacto");
  return data;
}

// =========================================================================
// FIRMA DE PARTE DE VISITA (30_firma_visitas.sql)
// =========================================================================
// 🔒 INVARIANTE: una visita se FIRMA EN SU INSERT o no se firma nunca. NO existe
// —a propósito— ningún UPDATE de `firma`. El sello temporal es `created_at`
// (server now()): NO lo mandamos, para que salte el default y no lo ponga el
// cliente. Para firmar una visita ya registrada, se crea OTRA fila.
async function firmarVisita({ inmueble_id, cliente_id, fecha = null, firma } = {}) {
  const sb = getClient();
  if (!inmueble_id) throw new Error("Falta el inmueble de la visita.");
  if (!cliente_id) throw new Error("Falta el cliente/firmante de la visita.");
  if (!firma || typeof firma !== "object") throw new Error("Firma no válida.");
  // Snapshot y trazo son OBLIGATORIOS: sin ellos el parte no prueba nada.
  if (!firma.snapshot || !String(firma.snapshot).trim()) throw new Error("Falta el texto firmado (snapshot).");
  if (!firma.trazo || !Array.isArray(firma.trazo.data) || firma.trazo.data.length === 0) {
    throw new Error("Falta el trazo de la firma.");
  }
  // Agente = usuario logueado (mismo patrón que crearTarea). Va a comercial_id y
  // se sella también dentro del jsonb junto al user agent.
  const { data: { user } } = await sb.auth.getUser();
  let agenteId = null;
  if (user) {
    const { data: u } = await sb.from("usuarios").select("id")
      .or(`auth_user_id.eq.${user.id},email.eq.${user.email}`).limit(1).maybeSingle();
    agenteId = u?.id || null;
  }
  const firmaFull = {
    ...firma,
    agente_id: agenteId,
    user_agent: (typeof navigator !== "undefined" && navigator.userAgent) || null,
  };
  const { data, error } = await sb.from("visitas").insert(await conOrg({
    inmueble_id,
    cliente_id,
    fecha: fecha || new Date().toISOString(),   // fecha de la VISITA (semántica)
    comercial_id: agenteId,
    firma: firmaFull,
    // created_at NO se manda → default now() en el servidor = SELLO de firma.
  })).select().single();
  if (error) throw error;
  return data;
}

// UPDATE que SOLO toca las notas comerciales. NUNCA envía `firma` ni el snapshot:
// el bloque firmado es inmutable. Editar la valoración del lead ≠ re-firmar.
async function actualizarNotasVisita(visitaId, { valoracion = null, comentario = null, objeciones = null, siguiente_paso = null } = {}) {
  const sb = getClient();
  const { data, error } = await sb.from("visitas")
    .update({ valoracion, comentario, objeciones, siguiente_paso })
    .eq("id", visitaId).select().single();
  if (error) throw error;
  return data;
}

// Write-back de datos declarados al firmar → ficha del cliente. Rellena SOLO lo
// que falta. Si ya hay un DNI DISTINTO guardado, NO lo pisa salvo `forzarDni`
// explícito (el aviso al usuario vive en la UI). Devuelve si hubo conflicto.
async function completarDatosCliente(clienteId, { dni_cif = null, telefono = null } = {}, { forzarDni = false } = {}) {
  const sb = getClient();
  const { data: cli } = await sb.from("clientes").select("dni_cif, telefono").eq("id", clienteId).maybeSingle();
  const dniExistente = (cli && cli.dni_cif && String(cli.dni_cif).trim()) || null;
  const conflictoDni = !!(dni_cif && dniExistente && dniExistente.toUpperCase() !== String(dni_cif).trim().toUpperCase());
  const patch = {};
  if (telefono && !(cli && cli.telefono)) patch.telefono = telefono;
  if (dni_cif && (!dniExistente || forzarDni)) patch.dni_cif = String(dni_cif).trim();
  if (Object.keys(patch).length === 0) return { actualizado: false, conflictoDni, dniExistente };
  const { data, error } = await sb.from("clientes").update(patch).eq("id", clienteId).select().single();
  if (error) throw error;
  return { actualizado: true, conflictoDni, dniExistente, campos: Object.keys(patch), cliente: data };
}

// =========================================================================
// ESCRITURA — geolocalización desde el mapa
// =========================================================================

/**
 * Ata un inmueble nuestro a una parcela del Catastro, desde el mapa.
 *
 * Es la salida para los que quedaron mal ubicados: en vez de pelearse con el
 * geocodificador editando la dirección, se pincha la parcela buena y se asigna.
 *
 * `geo_confianza = 'manual'` a propósito: no es `portal`. Lo ha puesto una
 * persona señalando en un mapa, y esa distinción es justo la que evita que
 * mañana nadie sepa de dónde salió la coordenada.
 *
 * La referencia de UNIDAD (20 dígitos) solo se escribe si la parcela tiene un
 * único inmueble. Si son 12, señalar el edificio no dice cuál es el nuestro, y
 * escribir una al azar sería inventarse un dato.
 */
async function asignarParcelaAInmueble(inmuebleId, { rc14, lng, lat, datos } = {}) {
  const sb = getClient();
  const unidades = (datos && datos.unidades) || [];
  const unica = unidades.length === 1 ? unidades[0] : null;

  const patch = {
    lat, lng,
    geo_confianza: "manual",
    geo_obtenido_at: new Date().toISOString(),
    ref_catastral_parcela: rc14,
    catastro_datos: {
      rc14,
      n_unidades: unidades.length,
      finca: (datos && datos.finca) || {},
      unidad: unica
        ? { rc20: unica.rc20, uso: unica.uso, superficie: unica.superficie,
            ano: unica.ano, planta: unica.planta, puerta: unica.puerta }
        : null,
      fuente: "OVC Catastro Consulta_DNPRC (datos no protegidos), asignada a mano en el mapa",
    },
    catastro_obtenido_at: new Date().toISOString(),
  };
  if (unica) patch.ref_catastral = unica.rc20;

  const { data, error } = await sb.from("inmuebles")
    .update(patch).eq("id", inmuebleId).select().single();
  if (error) throw error;
  return data;
}

/**
 * Da de alta un inmueble desde una parcela del Catastro, en fase Captación.
 *
 * CLAUDE.md §1 fija que un INMUEBLE existe UNA SOLA VEZ: una dirección, una
 * referencia catastral. Dos comerciales pasando por el mismo portal lo
 * duplicarían, asi que antes de insertar se mira si ya existe. La comprobacion
 * va EN SERVIDOR y no solo en la pantalla: entre que uno abre la ficha y le da
 * al boton, otro puede haberlo creado.
 *
 * Devuelve { creado: bool, inmueble }. Si ya existia, `creado` es false y se
 * devuelve el que hay: la pantalla ofrece abrirlo en vez de crear un gemelo.
 */
async function crearCaptacionDesdeParcela({ rc14, rc20, unidad, lng, lat, datos, faseCaptacionId } = {}) {
  const sb = getClient();

  /**
   * ¿Ya está en la cartera? Cada nivel de identidad bloquea el suyo, y la
   * comprobación de aquí dice lo MISMO que la de la pantalla.
   *
   * - Captando una unidad: solo bloquea esa misma unidad de 20 dígitos.
   *   Antes bloqueaba tambien cualquier inmueble de la parcela, asi que
   *   captar el 3ºB de un edificio del que ya tenemos el 1ºA —que la ficha
   *   ofrece explicitamente— lo rechazaba el servidor diciendo "ya existia".
   * - Captando la parcela entera: bloquea solo si ya hay un registro DE LA
   *   PARCELA (con su referencia de 14 y sin unidad). Tener el 1ºA no impide
   *   captar el edificio: son cosas distintas. La ficha avisa igualmente.
   *
   * Sigue haciendose en servidor y no solo en pantalla porque entre que se
   * abre la ficha y se pulsa, otro comercial puede haberla creado.
   */
  let q = sb.from("inmuebles").select("*");
  q = rc20 ? q.eq("ref_catastral", rc20)
           : q.eq("ref_catastral_parcela", rc14).is("ref_catastral", null);
  const { data: existentes, error: errBusca } = await q.limit(1);
  if (errBusca) throw errBusca;
  if (existentes && existentes.length) return { creado: false, inmueble: existentes[0] };

  const u = unidad || {};
  const us = (datos && datos.unidades) || [];
  const finca = (datos && datos.finca) || {};
  const deParcela = !rc20;   // captacion de la parcela ENTERA, no de una unidad

  /**
   * Uso catastral -> tipo del CRM.
   *
   * TODOS los valores que salgan de aqui tienen que existir en el enum
   * `tipo_inmueble` de Postgres: piso, casa, local, nave, garaje, traspaso,
   * oficina, terreno, trastero, edificio, chalet, parcela.
   *
   * Antes el caso por defecto era "otro", que NO esta en el enum. Comprobado
   * contra la base el 28-ago-2026: captar una nave ("Industrial") o una
   * parcela rustica ("Agrario") habria reventado el insert con
   * «invalid input value for enum tipo_inmueble: "otro"». Y el Catastro
   * devuelve muchos usos mas: Ocio y Hosteleria, Cultural, Sanidad,
   * Deportivo, Religioso, Espectaculos, Edificio singular.
   */
  const sinDivision = /sin división/i.test(finca.tipo || "");
  const tipoDeUso = (uso) =>
    uso === "Residencial" ? (sinDivision ? "casa" : "piso")
      : uso === "Almacen-Estacionamiento" ? "garaje"
      : uso === "Comercial" ? "local"
      : uso === "Oficinas" ? "oficina"
      : uso === "Industrial" ? "nave"
      : uso === "Agrario" ? "terreno"
      : "local";   // el resto son locales dentro de un edificio; se corrige en la ficha

  // La direccion de la parcela entera: el Catastro solo manda `ldt` cuando la
  // finca no tiene division horizontal. Con varias unidades hay que sacarla de
  // ellas, quedandose con la calle mayoritaria (un bloque puede dar a dos).
  const viaMayoritaria = () => {
    const cuenta = new Map();
    us.forEach((x) => { if (x.via) cuenta.set(x.via, (cuenta.get(x.via) || 0) + 1); });
    let mejor = null, n = 0;
    cuenta.forEach((v, k) => { if (v > n) { n = v; mejor = k; } });
    return mejor;
  };

  // Un año para toda la parcela solo si TODAS las unidades coinciden. Poner el
  // de la primera seria inventarselo.
  const anos = [...new Set(us.map((x) => x.ano).filter((a) => a != null))];
  const cabecera = us[0] || {};

  /**
   * El Catastro manda el municipio en MAYUSCULAS y SIN TILDES, asi que
   * "JAEN" se capitaliza a "Jaen". La cartera lo tiene escrito "Jaén", y el
   * 28-ago-2026 las dos primeras captaciones reales nacieron en "Jaen":
   * dos municipios distintos para el mismo sitio, que rompe cualquier
   * agrupacion o filtro por municipio.
   *
   * Si ya tenemos ese municipio escrito bien, se usa el nuestro. Las tildes
   * no se pueden adivinar; lo que si se puede es no inventar un municipio
   * nuevo cuando ya existe.
   */
  const sinTildes = (t) => String(t || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
  const municipioCanonico = (m) => {
    if (!m) return null;
    const cartera = (window.DATA && window.DATA.INMUEBLES) || [];
    const ya = cartera
      .map((p) => p.town || p.municipio)
      .find((x) => x && x !== "—" && sinTildes(x) === sinTildes(m));
    return ya || m;
  };

  const campos = {
    direccion: (datos && datos.ldt) || (deParcela ? viaMayoritaria() : u.via) || null,
    municipio: municipioCanonico(deParcela ? cabecera.municipio : u.municipio),
    cp: (deParcela ? cabecera.cp : u.cp) || null,
    tipo: deParcela
      ? (us.length === 0 ? "parcela" : us.length > 1 ? "edificio" : tipoDeUso(us[0].uso))
      : tipoDeUso(u.uso),
    // La superficie construida de la unidad. Una rustica declara 0 m²
    // construidos, y un inmueble de 0 m² no dice nada: en ese caso —y en la
    // parcela entera— vale la del suelo, que es la que importa.
    superficie: (!deParcela && u.superficie)
      ? u.superficie
      : (/^\d+$/.test(String(finca.suelo || "")) ? Number(finca.suelo) : null),
    ano_construccion: deParcela ? (anos.length === 1 ? anos[0] : null) : (u.ano != null ? u.ano : null),
    lat, lng,
    geo_confianza: "manual",
    geo_obtenido_at: new Date().toISOString(),
    ref_catastral_parcela: rc14,
    catastro_datos: {
      rc14,
      ambito: deParcela ? "parcela" : "unidad",
      n_unidades: us.length || null,
      finca,
      unidad: rc20 ? u : null,
      fuente: "OVC Catastro Consulta_DNPRC (datos no protegidos), alta desde el mapa",
    },
    catastro_obtenido_at: new Date().toISOString(),
  };
  if (rc20) campos.ref_catastral = rc20;

  // La fase se resuelve AQUI y no desde la UI: `DATA.PHASES` se mapea a
  // {num, key, label, short} y no lleva `id`, asi que pasarlo desde la
  // pantalla daba undefined y la captacion nacia sin fase, en silencio.
  let faseId = faseCaptacionId;
  if (!faseId) {
    const { data: fase } = await sb.from("fases_playbook")
      .select("id").eq("key", "captacion").maybeSingle();
    faseId = fase && fase.id;
  }
  if (faseId) campos.fase_id = faseId;

  return { creado: true, inmueble: await crearInmueble(campos) };
}

// =========================================================================
// ESCRITURA — alta de inmuebles y proyectos
// =========================================================================

/**
 * REFERENCIA INTERNA: CAJON-AA-NNN.
 *
 * Cuatro cajones — CAP captación, VEN venta, ALQ alquiler, FLIP operación
 * propia—, dos dígitos de año y una secuencia de tres.
 *
 * El 28-ago-2026 se renombraron los 56 inmuebles de la casa CONSERVANDO su
 * año y su número: el `25015` pasó a `VEN-25-015`. La idea es que el número
 * sea del inmueble para toda su vida y que al cambiar de cajón solo cambie
 * el prefijo (`CAP-26-014` → `VEN-26-014`), para no romper la memoria de
 * quien lleva años diciendo "el 26014". La anterior queda en
 * `ref_anterior` y el buscador la sigue encontrando.
 *
 * Por eso el contador es UNO POR AÑO y no uno por cajón: si cada cajón
 * llevara el suyo, `VEN-26-014` y `CAP-26-014` serían inmuebles distintos y
 * decir "el 014" dejaría de identificar a nadie.
 */
const CAJONES_REF = ["CAP", "VEN", "ALQ", "FLIP"];
const REF_RE = /^(?:CAP|VEN|ALQ|FLIP)-(\d{2})-(\d{3})$/;
// Las de antes del renombrado, por si queda alguna suelta o llega de fuera.
const REF_RE_VIEJA = /^(\d{2})(\d{3})$/;

const cajonDeRef = (ref) => String(ref || "").split("-")[0] || null;

/**
 * Cambia el cajón de una referencia conservando año y número.
 * `CAP-26-014` → `VEN-26-014`. Devuelve null si la referencia no tiene el
 * formato nuevo: renombrar a ciegas es peor que no renombrar.
 */
function refEnCajon(ref, cajon) {
  const m = REF_RE.exec(String(ref || ""));
  if (!m || !CAJONES_REF.includes(cajon)) return null;
  return `${cajon}-${m[1]}-${m[2]}`;
}

/**
 * Siguiente referencia libre del año en curso, en el cajón que se pida.
 *
 * Usa el MÁXIMO del año y no el conteo: hay huecos —en 2025 hay 31 inmuebles
 * y la secuencia llega al 046—, así que contar devolvería un número ya usado.
 * Mira los cuatro cajones a la vez, porque el contador es del año.
 */
async function siguienteRef(anio, cajon = "CAP") {
  const sb = getClient();
  const aa = String((anio ?? new Date().getFullYear()) % 100).padStart(2, "0");
  const caj = CAJONES_REF.includes(cajon) ? cajon : "CAP";
  // Se piden también las anteriores: un número que ya se usó y luego se
  // renombró no se vuelve a repartir.
  const { data, error } = await sb.from("inmuebles").select("ref, ref_anterior");
  if (error) throw error;
  let seq = 0;
  const mirar = (valor) => {
    const t = String(valor || "");
    const m = REF_RE.exec(t) || REF_RE_VIEJA.exec(t);
    if (m && m[1] === aa) seq = Math.max(seq, Number(m[2]));
  };
  (data || []).forEach((r) => { mirar(r.ref); mirar(r.ref_anterior); });
  return `${caj}-${aa}-${String(seq + 1).padStart(3, "0")}`;
}

// Alta de inmueble. `ref` es NOT NULL + UNIQUE: si otra alta simultánea se ha
// llevado la referencia, se regenera y se reintenta.
/**
 * Mueve un inmueble de cajón: `CAP-26-014` → `VEN-26-014`.
 *
 * Cambia SOLO el prefijo. El año y el número son del inmueble para toda su
 * vida, así que quien lo conoce por "el 26014" lo sigue conociendo. La que
 * tenía queda en `ref_anterior` y el buscador la sigue encontrando.
 *
 * Se hace con un botón y no al cambiar de fase a propósito: renombrar cambia
 * cómo habla el equipo de un inmueble, y que ocurra solo porque alguien
 * arrastró una tarjeta en el pipeline es de las cosas que luego nadie sabe
 * explicar.
 */
async function cambiarCajonDeInmueble(id, cajon) {
  const sb = getClient();
  const { data: fila, error: errLeer } = await sb.from("inmuebles")
    .select("ref").eq("id", id).single();
  if (errLeer) throw errLeer;

  const nueva = refEnCajon(fila.ref, cajon);
  if (!nueva) {
    throw new Error(
      `«${fila.ref}» no tiene el formato por cajones, así que no se puede mover sin inventarle un número. Hay que corregirla en base de datos: la ficha no deja tocar la referencia.`,
    );
  }
  if (nueva === fila.ref) return { cambiado: false, ref: fila.ref };

  const ahora = new Date().toISOString();
  const { data, error } = await sb.from("inmuebles")
    .update({ ref: nueva, ref_anterior: fila.ref, updated_at: ahora, ultima_actividad_at: ahora })
    .eq("id", id).select().single();
  if (error) {
    // `ref` es UNIQUE: si el número ya está ocupado en ese cajón, es que hay
    // dos inmuebles con el mismo número y eso no lo arregla un reintento.
    if (error.code === "23505") {
      throw new Error(`Ya existe un inmueble con la referencia ${nueva}. Hay dos con el mismo número: hay que resolverlo a mano.`);
    }
    throw error;
  }
  if (!data) throw new Error("No se ha podido cambiar de cajón: el inmueble no existe o no tienes permiso.");
  return { cambiado: true, ref: data.ref, anterior: fila.ref, inmueble: data };
}

/**
 * Trae la fila CRUDA de un inmueble, tal cual está en la base.
 *
 * `DATA.INMUEBLES` viene mapeada para pintar (addr, town, type…). Para
 * rellenar un formulario de edición hace falta lo contrario: los nombres y
 * los valores de las columnas. Deshacer el mapeo seria mantener una segunda
 * verdad; se pide la fila y ya.
 */
async function cargarInmuebleCrudo(id) {
  const sb = getClient();
  const { data, error } = await sb.from("inmuebles").select("*").eq("id", id).single();
  if (error) throw error;
  return data;
}

/**
 * Actualiza un inmueble.
 *
 * Hasta el 28-ago-2026 el CRM NO PODÍA editar un inmueble. El único UPDATE
 * sobre la tabla en todo el cliente era el de asignar parcela desde el mapa:
 * se daba de alta y lo que quedara mal, quedaba mal para siempre. Carlos:
 * "tenemos que tener una pantalla en la que podamos crear los inmuebles y
 * luego podamos editarlos".
 *
 * `ref`, `id` y `org_id` no viajan nunca: la referencia es la identidad del
 * inmueble, y la organización la impone la RLS (política `org_isolation`,
 * RESTRICTIVA). Tampoco viaja nada de dinero interno: este formulario no lo
 * toca, y el que edite no tiene por qué poder tocarlo.
 *
 * Quién puede: la política de UPDATE es
 * `is_admin() OR (is_staff_activo() AND es_inmueble_comercial(id))`. Un
 * comercial puede con los suyos —una captación recién creada no tiene
 * proyecto de flip, así que `es_inmueble_comercial` da verdadero— y no puede
 * con los flips de la casa.
 */
async function actualizarInmueble(id, campos) {
  const sb = getClient();
  const patch = { ...campos };
  ["id", "ref", "org_id", "created_at",
   "precio_compra", "coste_reforma", "coste_real_total", "gastos_totales"].forEach((k) => delete patch[k]);
  const ahora = new Date().toISOString();
  patch.updated_at = ahora;
  patch.ultima_actividad_at = ahora;

  const { data, error } = await sb.from("inmuebles")
    .update(patch).eq("id", id).select().single();
  if (error) throw error;
  // .single() con 0 filas ya lanza; esto cubre el caso de que RLS deje pasar
  // el update pero filtre la lectura de vuelta.
  if (!data) throw new Error("No se ha podido guardar: el inmueble no existe o no tienes permiso para editarlo.");
  return data;
}

async function crearInmueble(campos) {
  const sb = getClient();
  let ref = campos.ref || null;
  for (let intento = 0; intento < 5; intento++) {
    if (!ref) ref = await siguienteRef();
    const { data, error } = await sb.from("inmuebles").insert(await conOrg({ ...campos, ref })).select().single();
    if (!error) return data;
    const esRefDuplicada = error.code === "23505" && /ref/i.test(error.message || "");
    if (!esRefDuplicada) throw error;
    ref = null;   // regenerar y reintentar
  }
  throw new Error("No se pudo asignar una referencia libre. Inténtalo de nuevo.");
}

// Alta de proyecto sobre un inmueble. Un inmueble puede tener N proyectos
// (CLAUDE.md §1): esto AÑADE, nunca sustituye. Sin campos de balance/gastos:
// están deprecados y se calculan desde `movimientos`.
async function crearProyecto(campos) {
  const sb = getClient();
  const { data, error } = await sb.from("proyectos").insert(await conOrg(campos)).select().single();
  if (error) throw error;
  return data;
}

// Honorario de intermediación como MOVIMIENTO propio con su pagador
// (CLAUDE.md §4): admite 0, 1 o N por proyecto — uno por cada parte a la que
// se le cobre, con importes independientes. Nace DEVENGADO y sin cobrar:
// REVIN solo cobra al CIERRE, así que entra en `balance` pero no en
// `balance_cobrado`. Siempre PLANO 2 (contable).
async function crearHonorario({ proyecto_id, inmueble_id = null, importe, pagador_nombre, pagador_id = null, vencimiento = null, concepto }) {
  const sb = getClient();
  const { data: emisor } = await sb.from("emisores")
    .select("id").eq("tipo", "sociedad").limit(1).maybeSingle();
  const { data, error } = await sb.from("movimientos").insert(await conOrg({
    tipo: "ingreso",
    concepto: concepto || "Honorarios de intermediación",
    importe_total: importe,
    categoria: "Honorarios",
    estado: "pendiente",
    plano: "contable",
    proyecto_id,
    inmueble_id,
    emisor_id: emisor?.id || null,
    parte_origen: pagador_nombre || null,
    parte_origen_id: pagador_id,
    parte_destino: "REVIN PROPTECH S.L.",
    cliente_nombre: pagador_nombre || null,
    vencimiento,
    notas: "Devengado y NO cobrado. Se cobra al cierre de la operación.",
  })).select().single();
  if (error) throw error;
  return data;
}

// =========================================================================
// ESCRITURA — MOTOR DE COMISIONES (Fases 4 y 5)
// =========================================================================

// % por defecto del comercial (ficha de Equipo). Es la SEMILLA de la previsión,
// no su valor definitivo: cada operación puede llevar un override en
// `comisiones_equipo.pct_aplicado`. Cambiar esto NO recalcula previsiones ya
// guardadas — y así debe ser: son acuerdos ya cerrados.
async function actualizarComisionPct(usuarioId, pct) {
  const sb = getClient();
  const valor = (pct === "" || pct == null) ? null : Number(pct);
  if (valor != null && (Number.isNaN(valor) || valor < 0 || valor > 100)) {
    throw new Error("El porcentaje debe estar entre 0 y 100.");
  }
  const { data, error } = await sb.from("usuarios")
    .update({ comision_pct: valor }).eq("id", usuarioId).select().single();
  if (error) throw error;
  return data;
}

// Comercial responsable del proyecto. `comercial_nombre` se mantiene en sync
// porque es el campo que heredó el import de Notion y aún lo leen vistas viejas.
async function asignarComercialProyecto(proyectoId, comercialId, comercialNombre) {
  const sb = getClient();
  const { data, error } = await sb.from("proyectos")
    .update({
      comercial_id: comercialId || null,
      comercial_nombre: comercialId ? (comercialNombre || null) : null,
    })
    .eq("id", proyectoId).select().single();
  if (error) throw error;
  return data;
}

// Alta/edición de la PREVISIÓN de comisión. Mientras no se materialice no es
// gasto y no computa en ningún balance (CLAUDE.md §4).
//
// `base_origen` lo decide el llamante comparando con la propuesta del motor:
// 'manual' deja constancia de que la base se tocó a mano. Es el caso real de
// Llana de San Juan 41 — "30% de 4.500 €" cuando el sistema suma 4.920 €.
async function guardarPrevisionComision({
  comisionId = null, proyectoId = null, inmuebleId = null, comercialId,
  base, pct, baseOrigen = "calculada", baseMovimientoIds = [], notas = null,
}) {
  const sb = getClient();
  if (!comercialId) throw new Error("Asigna primero un comercial responsable.");
  if (!proyectoId && !inmuebleId) throw new Error("La comisión debe colgar de un proyecto o de un inmueble.");
  const baseNum = Number(base), pctNum = Number(pct);
  if (Number.isNaN(baseNum) || baseNum < 0) throw new Error("La base debe ser un importe válido.");
  if (Number.isNaN(pctNum) || pctNum < 0 || pctNum > 100) throw new Error("El porcentaje debe estar entre 0 y 100.");

  const payload = {
    proyecto_id: proyectoId, inmueble_id: inmuebleId,
    comercial_id: comercialId,
    base_calculo: baseNum, pct_aplicado: pctNum,
    base_origen: baseOrigen, base_movimiento_ids: baseMovimientoIds || [],
    notas,
  };
  const q = comisionId
    ? sb.from("comisiones_equipo").update(payload).eq("id", comisionId)
    : sb.from("comisiones_equipo").insert(await conOrg(payload));
  const { data, error } = await q.select().single();
  if (error) throw error;
  return data;
}

// Borra una previsión NO materializada. El guard `estado <> 'pagada'` está en
// cliente Y en la BD (el CHECK `pagada ⇒ movimiento_id not null` no impide el
// delete, pero el ON DELETE SET NULL dejaría un gasto huérfano): si ya es gasto
// real, se revierte desde contabilidad, no borrando la previsión.
async function borrarPrevisionComision(comisionId) {
  const sb = getClient();
  const { data, error } = await sb.from("comisiones_equipo")
    .delete().eq("id", comisionId).neq("estado", "pagada").select();
  if (error) throw error;
  if (!data || !data.length) {
    throw new Error("No se ha borrado: la comisión ya está pagada. Reviértela desde Contabilidad.");
  }
  return data[0];
}

// FASE 5 · el salto que toca la contabilidad. Delega en la función SQL
// `materializar_comision_equipo` (26_materializar_comision.sql) porque las dos
// escrituras —crear el movimiento y marcar la previsión— tienen que ser
// atómicas: hacerlas por separado desde aquí puede dejar un GASTO REAL huérfano
// si falla la red en medio. La precondición (proyecto finalizado), el
// anti-duplicado (27_) y la idempotencia viven en el servidor, no aquí: el
// invariante tiene que sobrevivir a cualquier llamante, no solo a este front.
//
// `forzar` es el escape para la excepción legítima (dos comisiones reales al
// mismo comercial en la misma operación, p.ej. un alquiler renovado). Solo debe
// mandarse tras un acto deliberado del usuario, nunca por defecto.
async function materializarComision(comisionId, { fecha = null, metodo = null, forzar = false } = {}) {
  const sb = getClient();
  const { data, error } = await sb.rpc("materializar_comision_equipo", {
    p_comision_id: comisionId,
    p_fecha: fecha,     // null ⇒ current_date en el servidor
    p_metodo: metodo,
    p_forzar: !!forzar,
  });
  if (error) {
    // 23505 = la operación ya tiene una comisión pagada a ese comercial. Se
    // marca para que la tarjeta pueda ofrecer el forzado en vez de tratarlo
    // como un error genérico.
    if (error.code === "23505") { const e = new Error(error.message); e.duplicado = true; throw e; }
    throw error;
  }
  return data;
}

async function getSession() {
  const sb = getClient();
  const { data: { session } } = await sb.auth.getSession();
  return session;
}

// =========================================================================
// LOGIN MODAL — React component
// =========================================================================
const SupabaseLoginModal = ({ onSuccess, onCancel }) => {
  const { Icons } = window;
  const [email, setEmail] = React.useState("");
  const [password, setPassword] = React.useState("");
  const [loading, setLoading] = React.useState(false);
  const [googleCargando, setGoogleCargando] = React.useState(false);
  const [error, setError] = React.useState(null);

  const entrarConGoogle = async () => {
    setGoogleCargando(true); setError(null);
    try {
      await signInWithGoogle();
      // Si todo va bien el navegador ya se está yendo a Google y este
      // componente se desmonta. No se apaga el "cargando" a propósito: dejarlo
      // encendido evita que el botón parezca pulsable durante la redirección.
    } catch (err) {
      console.error(err);
      setError(err.message || "No se pudo abrir el acceso con Google");
      setGoogleCargando(false);
    }
  };

  const submit = async (e) => {
    e.preventDefault();
    setLoading(true); setError(null);
    try {
      await signInEmailPassword(email, password);
      const data = await loadDataFromSupabase();
      window.DATA = data;
      onSuccess();
    } catch (err) {
      console.error(err);
      // Si el login tuvo éxito pero la carga falló, cerramos sesión para NO dejar
      // una sesión activa mostrando datos de demostración (invariante: sesión ⇒
      // datos reales; nunca mocks con sesión). Si el fallo fue el propio login,
      // el signOut es inocuo.
      try { await signOut(); } catch (_) {}
      setError(err.message || "Error desconocido");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="sb-login-back" onClick={onCancel}>
      <form className="sb-login" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
        <div className="sb-login-h">
          <img src={(window.__resources && window.__resources.logoBlack) || "revin/assets/logo-wordmark-black.png"} alt="REVIN" style={{height: 22}}/>
        </div>
        <h2 className="sb-login-title">Entrar</h2>
        <p className="sb-login-sub">Con tu cuenta de REVIN. Si no tienes acceso, pídeselo a Carlos.</p>

        {/* Google va PRIMERO y es el camino normal: sin contraseña que repartir,
            y quien deja la empresa pierde el acceso al cerrarle el correo. El
            correo y contraseña se queda debajo para las cuentas que aún no
            tienen buzón de Workspace. */}
        <button type="button" className="btn sb-google" onClick={entrarConGoogle} disabled={loading || googleCargando}>
          <svg width="16" height="16" viewBox="0 0 48 48" aria-hidden="true">
            <path fill="#4285F4" d="M45.1 24.5c0-1.6-.1-3.1-.4-4.5H24v8.5h11.8c-.5 2.7-2 5-4.4 6.6v5.5h7.1c4.2-3.8 6.6-9.5 6.6-16.1z"/>
            <path fill="#34A853" d="M24 46c5.9 0 10.9-2 14.5-5.4l-7.1-5.5c-2 1.3-4.5 2.1-7.4 2.1-5.7 0-10.5-3.8-12.2-9H4.5v5.7C8.1 41.1 15.5 46 24 46z"/>
            <path fill="#FBBC05" d="M11.8 28.2c-.4-1.3-.7-2.7-.7-4.2s.3-2.9.7-4.2v-5.7H4.5C3 17.1 2.1 20.4 2.1 24s.9 6.9 2.4 9.9l7.3-5.7z"/>
            <path fill="#EA4335" d="M24 10.8c3.2 0 6.1 1.1 8.4 3.3l6.3-6.3C34.9 4.2 29.9 2 24 2 15.5 2 8.1 6.9 4.5 14.1l7.3 5.7c1.7-5.2 6.5-9 12.2-9z"/>
          </svg>
          {googleCargando ? "Abriendo Google…" : "Continuar con Google"}
        </button>

        <div className="sb-login-o"><span>o con tu correo</span></div>

        <div className="field">
          <label className="field-lbl">Email</label>
          <input
            className="input"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="tu@email.com"
            autoFocus
            required
          />
        </div>
        <div className="field" style={{marginTop: 12}}>
          <label className="field-lbl">Contraseña</label>
          <input
            className="input"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="••••••••"
            required
          />
        </div>

        {error && (
          <div className="sb-login-err">
            <Icons.Alert/>
            <span>{error}</span>
          </div>
        )}

        <div className="sb-login-actions">
          <button type="button" className="btn ghost" onClick={onCancel} disabled={loading}>
            Usar datos de prueba
          </button>
          <button type="submit" className="btn primary" disabled={loading}>
            {loading ? "Conectando..." : <><Icons.ArrowRight/> Conectar</>}
          </button>
        </div>

        <div className="sb-login-foot">
          <Icons.Info size={12}/> Solo por invitación: tu correo tiene que estar dado de alta.
        </div>
      </form>
    </div>
  );
};

// ---------------------------------------------------------------------------
// ¿Esta sesión ve la caja de la empresa?
// ---------------------------------------------------------------------------
// Un único sitio donde se decide, en vez de repartir `isAdmin` por seis
// componentes: la ficha de inmueble y la de proyecto no reciben esa prop, y
// atravesarla habría significado tocar la firma de media docena de vistas para
// que ninguna se olvidara. Una función global se olvida en un sitio o en
// ninguno.
//
// Tres estados y no dos:
//  - Sin sesión (datos de demostración): sí. No hay nada real que proteger y
//    la maqueta tiene que poder enseñarse entera.
//  - Sesión de admin: sí.
//  - Sesión de cualquier otro rol: NO.
//
// Y esto no es lo que protege el dinero —eso es RLS, que ya le devuelve cero
// filas de `movimientos` a un comercial (medido)—. Esto evita lo OTRO: que la
// pantalla pinte "Ingresos 0 € · Gastos 0 € · Balance 0 €" con las cuatro
// cifras a cero y parezca una cuenta hecha. Ese cero no es un dato, es una
// puerta cerrada, y la diferencia no se ve en pantalla.
window.veLaCaja = function () {
  const u = (window.DATA && window.DATA.USER) || null;
  // El mock no trae `rol` (solo la etiqueta `role`): sin él, estamos en la
  // maqueta y no hay nada que ocultar.
  if (!u || !u.rol) return true;
  return u.rol === "admin";
};

// Inject login modal styles
const _injectStyles = () => {
  if (document.getElementById("sb-login-styles")) return;
  const s = document.createElement("style");
  s.id = "sb-login-styles";
  s.textContent = `
    .sb-login-back { position: fixed; inset: 0; background: rgba(22,26,29,.7); display: flex; align-items: center; justify-content: center; z-index: 300; backdrop-filter: blur(4px); }
    .sb-login { background: var(--bg-surface); border: 1px solid var(--border-2); border-radius: 8px; padding: 32px; width: min(440px, 92vw); box-shadow: var(--shadow-lg); }
    .sb-login-h { display: flex; justify-content: space-between; align-items: center; padding-bottom: 20px; border-bottom: 1px solid var(--border-1); margin-bottom: 20px; }
    .sb-google { width: 100%; justify-content: center; gap: 10px; background: var(--bg-surface); border: 1px solid var(--border-2); color: var(--fg-1); font-weight: 600; padding: 11px 16px; }
    .sb-google:hover:not(:disabled) { background: var(--bg-2, var(--bg-surface)); border-color: var(--fg-4); }
    .sb-login-o { display: flex; align-items: center; gap: 12px; margin: 18px 0 14px; color: var(--fg-4); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
    .sb-login-o::before, .sb-login-o::after { content: ""; flex: 1; height: 1px; background: var(--border-1); }
    .sb-login-tag { font-family: var(--font-mono); font-size: 9px; font-weight: 700; letter-spacing: .12em; background: var(--revin-red); color: #fff; padding: 3px 8px; border-radius: 2px; }
    .sb-login-title { margin: 0 0 6px; font-family: var(--font-display); font-size: 24px; letter-spacing: .02em; text-transform: uppercase; }
    .sb-login-sub { font-size: 13px; color: var(--fg-2); line-height: 1.5; margin: 0 0 20px; }
    .sb-login-err { background: var(--danger-bg); color: var(--danger-fg); padding: 10px 12px; border-radius: 4px; font-size: 12px; margin-top: 16px; display: flex; gap: 8px; align-items: flex-start; }
    .sb-login-err .icon { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; fill: none; flex-shrink: 0; margin-top: 1px; }
    .sb-login-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
    .sb-login-foot { font-size: 11px; color: var(--fg-3); margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-1); display: flex; align-items: center; gap: 6px; }
    .sb-login-foot code { font-family: var(--font-mono); font-size: 10px; background: var(--bg-inset); padding: 1px 4px; border-radius: 2px; color: var(--fg-1); }
    .sb-login-foot .icon { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; fill: none; }
  `;
  document.head.appendChild(s);
};
_injectStyles();

// =========================================================================
// STORAGE — subida de documentos (Fase 0, criterio 3)
// =========================================================================
// Buckets creados en `revin/supabase/39_fase0_storage.sql`:
//   `documentos`      PRIVADO  · todo lo demás
//   `fotos-publicas`  PÚBLICO  · SOLO tipo `foto_portal` (escaparate)
//
// RUTA: {org_id}/{entidad}/{id}/{NOMBRE}.ext  — el org_id SIEMPRE delante,
// porque la política lee `(storage.foldername(name))[1]`. La ruta es del
// control de acceso; el NOMBRE es la convención REVIN. Ejes distintos.
// Formatea el tamaño por TRAMOS. Antes era `Math.round(bytes/1024) + " KB"`,
// que para cualquier fichero de menos de 512 bytes daba **"0 KB"** — y "0 KB"
// se lee como "el archivo está vacío", así que mandaba a resubir documentos
// que estaban perfectos. El dato en base siempre fue correcto; fallaba la
// presentación.
function fmtTamano(bytes) {
  const b = Number(bytes);
  if (bytes == null || Number.isNaN(b) || b < 0) return "—";
  if (b < 1024) return b + " B";
  if (b < 1024 * 1024) return Math.round(b / 1024) + " KB";
  const mb = b / (1024 * 1024);
  return (mb < 10 ? mb.toFixed(1) : Math.round(mb)) + " MB";
}

const DOC_BUCKET_PRIVADO = "documentos";
const DOC_BUCKET_PUBLICO = "fotos-publicas";
const DOC_TIPOS_PUBLICOS = new Set(["foto_portal"]);

const DOC_TIPOS = [
  ["nota_simple", "Nota simple"], ["escritura", "Escritura"],
  ["contrato_reserva", "Contrato de reserva"], ["contrato_arras", "Contrato de arras"],
  ["hoja_encargo", "Hoja de encargo"], ["cee", "CEE"], ["factura", "Factura"],
  ["presupuesto", "Presupuesto"], ["plano", "Plano"],
  ["foto", "Foto (interna)"], ["foto_portal", "Foto de portal · PÚBLICA"],
  ["otro", "Otro"],
];

function bucketDeTipo(tipo) {
  return DOC_TIPOS_PUBLICOS.has(tipo) ? DOC_BUCKET_PUBLICO : DOC_BUCKET_PRIVADO;
}

// El nombre viaja dentro de la clave del objeto: fuera acentos, ñ y espacios.
// Base en MAYÚSCULAS (convención REVIN), extensión en minúsculas.
function sanearNombreFichero(nombre) {
  const limpio = String(nombre || "documento")
    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
    .replace(/ñ/g, "n").replace(/Ñ/g, "N");
  const punto = limpio.lastIndexOf(".");
  const ext = punto > 0 ? limpio.slice(punto + 1).toLowerCase().replace(/[^a-z0-9]/g, "") : "";
  const base = (punto > 0 ? limpio.slice(0, punto) : limpio)
    .toUpperCase().replace(/[^A-Z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
  return ext ? `${base || "DOCUMENTO"}.${ext}` : (base || "DOCUMENTO");
}

// ⚠️ TRAMPA DE LA API DE STORAGE (medida el 03/08, ver docs/fase0/DEUDA_FASE0.md):
// cuando RLS rechaza una escritura, el transporte devuelve **HTTP 400** y es el
// CUERPO el que dice `{"statusCode":"403","code":"AccessDenied", ...}`.
// Despachar por `status === 400` convertiría un fallo de PERMISOS en un
// "archivo inválido" y en soporte se perseguiría el fichero en vez del permiso.
// Por eso aquí se mira el cuerpo, no el status.
function _mensajeErrorStorage(error) {
  const msg = String((error && error.message) || "");
  const status = String((error && (error.statusCode ?? error.status)) ?? "");
  if (status === "403" || /row-level security|AccessDenied|Unauthorized/i.test(msg)) {
    return "No tienes permiso para subir en esta ubicación. Si has cambiado de organización, cierra sesión y vuelve a entrar.";
  }
  if (status === "409" || /already exists|Duplicate/i.test(msg)) {
    return "Ya existe un documento con ese nombre en este expediente. Renómbralo antes de subirlo.";
  }
  if (/mime|content type|not allowed/i.test(msg)) {
    return "Ese tipo de archivo no se admite aquí: las fotos de portal solo aceptan imágenes (JPG, PNG, WebP o AVIF).";
  }
  if (status === "413" || /exceeded|maximum allowed size|too large|Payload/i.test(msg)) {
    return "El archivo supera el tamaño máximo: 50 MB en documentos, 10 MB en fotos de portal.";
  }
  return "No se ha podido subir el archivo. Detalle: " + (msg || "error desconocido");
}

// Sube el fichero y registra la fila en `documentos`.
//
// ORDEN: subir -> insertar -> si el INSERT falla, BORRAR el objeto. Son dos
// escrituras sin transacción común: sin ese borrado, un fallo en el segundo
// paso deja un fichero huérfano en Storage que nadie ve desde el CRM. Es el
// mismo razonamiento que llevó a `materializar_comision_equipo()` a ser una
// función SQL en vez de dos llamadas desde el front.
//
// `org_id` va EXPLÍCITO, no por el DEFAULT del Paso B: ese default es deuda a
// retirar y no se le añaden escritores nuevos. Si no hay org resoluble, esto
// FALLA en vez de adivinar — un documento que no sabe de quién es no se guarda.
async function subirDocumento({ file, tipo = "otro", inmuebleId = null, clienteId = null, nombre = null } = {}) {
  const sb = getClient();
  if (!file) throw new Error("No has seleccionado ningún archivo.");
  if (!inmuebleId && !clienteId) throw new Error("El documento debe colgar de un inmueble o de un cliente.");

  const org = await resolverOrgId();
  if (!org) throw new Error("No se ha podido determinar tu organización. Cierra sesión y vuelve a entrar.");

  const entidad = inmuebleId ? "inmuebles" : "clientes";
  const entidadId = inmuebleId || clienteId;
  const fichero = sanearNombreFichero(nombre || file.name);
  const bucket = bucketDeTipo(tipo);
  const ruta = `${org}/${entidad}/${entidadId}/${fichero}`;

  // 1) SUBIR. `upsert: false` a propósito: un nombre repetido debe avisar,
  //    no pisar en silencio un documento legal ya subido.
  const { error: errUp } = await sb.storage.from(bucket)
    .upload(ruta, file, { contentType: file.type || undefined, upsert: false });
  if (errUp) throw new Error(_mensajeErrorStorage(errUp));

  // 2) REGISTRAR
  try {
    const { data: { user } } = await sb.auth.getUser();
    let usuarioId = null;
    if (user) {
      const { data: u } = await sb.from("usuarios").select("id")
        .or(`auth_user_id.eq.${user.id},email.eq.${user.email}`)
        .limit(1).maybeSingle();
      usuarioId = (u && u.id) || null;
    }
    // `storage_path` guarda `bucket/clave` (no solo la clave): así el fichero
    // se localiza sin volver a deducir el bucket desde `tipo`. Si mañana se
    // reclasifica un `foto` como `foto_portal`, el objeto NO se mueve y una
    // deducción por tipo apuntaría al bucket equivocado.
    const { data, error } = await sb.from("documentos").insert({
      org_id: org,
      inmueble_id: inmuebleId || null,
      cliente_id: clienteId || null,
      nombre: fichero,
      tipo,
      storage_path: `${bucket}/${ruta}`,
      tamano_bytes: file.size == null ? null : file.size,
      mime: file.type || null,
      subido_por: usuarioId,
    }).select().single();
    if (error) throw error;
    return data;
  } catch (e) {
    // 3) ROLLBACK del objeto
    try { await sb.storage.from(bucket).remove([ruta]); } catch (_) { /* nada que hacer */ }
    throw new Error(
      "El archivo se subió pero no se pudo registrar en el CRM, así que se ha eliminado para no dejar un huérfano. Detalle: " +
      ((e && e.message) || e)
    );
  }
}

// =========================================================================
// STORAGE — apertura/descarga de documentos
// =========================================================================
// CADUCIDAD de la URL firmada. 5 minutos, y el razonamiento importa:
//
// · La URL se genera EN EL CLIC, no al pintar la lista. Así que solo tiene
//   que sobrevivir desde el clic hasta que el navegador arranca la descarga
//   — segundos. Un TTL largo no compra nada.
// · Una URL firmada SE SALTA RLS por completo: quien la tenga, durante su
//   ventana, se descarga el fichero sin sesión ni organización. El TTL es
//   la ÚNICA protección que queda, así que cuanto más corto, mejor.
// · Pero no puede ser de 30 s: una escritura de 40 MB con mala conexión, o
//   un visor de PDF haciendo peticiones por rangos mientras el usuario lee,
//   necesitan más margen. 60 s se queda corto en cuanto el fichero pesa.
//
// 300 s es el punto donde una descarga real cabe de sobra y una URL que se
// escape (historial, un copy-paste en un chat) caduca antes de servir de nada.
const DOC_URL_TTL_SEGUNDOS = 300;

// `storage_path` se guarda como `bucket/clave`. Se parte por el PRIMER `/`
// porque la clave lleva más barras dentro ({org}/{entidad}/{id}/{fichero}).
function _partirStoragePath(storagePath) {
  const s = String(storagePath || "");
  const i = s.indexOf("/");
  if (i <= 0 || i === s.length - 1) return null;
  return { bucket: s.slice(0, i), clave: s.slice(i + 1) };
}

// Devuelve una URL utilizable para el documento.
//
// ⚠️ DERIVA POR BUCKET, NO POR TIPO. El bucket es dónde ESTÁ el fichero; el
// tipo es cómo se ha CLASIFICADO, y puede cambiar sin que el objeto se mueva.
// Reclasificar una `foto` como `foto_portal` no la traslada al bucket público:
// si dedujéramos por tipo, iríamos a buscarla donde no está.
async function urlDocumento(storagePath, nombreDescarga = null) {
  const sb = getClient();
  const p = _partirStoragePath(storagePath);
  if (!p) throw new Error("La ruta del documento no es válida. Puede ser un registro antiguo o incompleto.");

  // Bucket PÚBLICO: no hay nada que firmar, la URL es permanente.
  if (p.bucket === DOC_BUCKET_PUBLICO) {
    const { data } = sb.storage.from(p.bucket)
      .getPublicUrl(p.clave, nombreDescarga ? { download: nombreDescarga } : undefined);
    if (!data || !data.publicUrl) throw new Error("No se ha podido construir el enlace público del archivo.");
    return data.publicUrl;
  }

  // Bucket PRIVADO: URL firmada y temporal.
  const { data, error } = await sb.storage.from(p.bucket)
    .createSignedUrl(p.clave, DOC_URL_TTL_SEGUNDOS,
                     nombreDescarga ? { download: nombreDescarga } : undefined);
  if (error) throw new Error(_mensajeErrorStorage(error));
  if (!data || !data.signedUrl) throw new Error("No se ha podido firmar el enlace del archivo.");
  return data.signedUrl;
}

// Lanza la descarga. El `download` va en la URL (parámetro de Supabase) y no
// en el atributo del <a>: el atributo `download` se IGNORA entre orígenes, y
// Storage es otro origen — sin él, el PDF se abriría en una pestaña en vez de
// descargarse.
async function descargarDocumento(doc) {
  if (!doc || !doc.path) {
    throw new Error("Este documento no tiene ruta de archivo guardada, así que no se puede abrir.");
  }
  const url = await urlDocumento(doc.path, doc.name || null);
  const a = document.createElement("a");
  a.href = url;
  a.target = "_blank";
  a.rel = "noopener";
  document.body.appendChild(a);
  a.click();
  a.remove();
  return url;
}

// =========================================================================
// MODAL DE SUBIDA — compartido por las 3 pantallas
// =========================================================================
const SubirDocumentoModal = ({ inmuebleId = null, clienteId = null, onClose, onSubido, tipoInicial = "otro" }) => {
  const DATA = window.DATA || {};
  const [file, setFile] = React.useState(null);
  // `tipoInicial` para que "Añadir fotos" no obligue a buscar "Foto" en un
  // desplegable de once tipos cada vez que se sube una.
  const [tipo, setTipo] = React.useState(tipoInicial);
  const [destino, setDestino] = React.useState(inmuebleId || clienteId || "");
  const [err, setErr] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const fijo = !!(inmuebleId || clienteId);

  const submit = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    try {
      const inm = inmuebleId || (!clienteId && destino) || null;
      await window.RevinSupabase.subirDocumento({
        file, tipo, inmuebleId: inm || null, clienteId: clienteId || null,
      });
      if (onSubido) await onSubido();
      onClose && onClose();
    } catch (e2) {
      setErr((e2 && e2.message) || "Error desconocido.");
    } finally { setBusy(false); }
  };

  // ⚠️ Las clases NO son inventadas: son las mismas que usa `SupabaseLoginModal`
  // (`.sb-login-back` para el fondo fijo, `.sb-login` para la caja, `.field` /
  // `.field-lbl` / `.input` para los campos). Un `.sb-login-backdrop` o un `.fld`
  // no existen en ningún CSS del proyecto: el modal se renderizaba DENTRO del
  // flujo de la página, invisible al final del scroll. Sin build no hay nada que
  // avise de una clase que no existe.
  return (
    <div className="sb-login-back" onClick={() => { if (!busy) onClose && onClose(); }}>
      <form className="sb-login" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
        <div className="sb-login-h">
          <div className="sb-login-tag">DOCUMENTO</div>
        </div>
        <h2 className="sb-login-title">Subir documento</h2>
        <p className="sb-login-sub">
          Se guarda en el expediente y queda vinculado a tu organización.
        </p>

        <div className="field">
          <label className="field-lbl">Archivo</label>
          <input className="input" type="file" required
                 onChange={(e) => setFile(e.target.files && e.target.files[0])}/>
        </div>

        <div className="field">
          <label className="field-lbl">Tipo</label>
          <select className="input" value={tipo} onChange={(e) => setTipo(e.target.value)}>
            {DOC_TIPOS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
          </select>
        </div>

        {!fijo && (
          <div className="field">
            <label className="field-lbl">Inmueble</label>
            <select className="input" required value={destino} onChange={(e) => setDestino(e.target.value)}>
              <option value="">— Elige un inmueble —</option>
              {(DATA.INMUEBLES || []).map(i => (
                <option key={i._uid} value={i._uid}>{i.id} · {i.address || i.title || "—"}</option>
              ))}
            </select>
          </div>
        )}

        {DOC_TIPOS_PUBLICOS.has(tipo) && (
          <div className="sb-login-err" style={{ background: "var(--warning-bg)", color: "var(--warning-fg)" }}>
            <span>
              <b>Bucket público.</b> Las fotos de portal las puede ver <b>cualquiera con el enlace</b>,
              también fuera de REVIN. Es lo que permite mostrarlas en Idealista o Fotocasa.
              No subas aquí nada que no sea escaparate.
            </span>
          </div>
        )}

        {err && <div className="sb-login-err"><span>{err}</span></div>}

        <div className="sb-login-actions">
          <button type="button" className="btn ghost" onClick={onClose} disabled={busy}>Cancelar</button>
          <button type="submit" className="btn primary" disabled={busy || !file}>
            {busy ? "Subiendo…" : "Subir"}
          </button>
        </div>
      </form>
    </div>
  );
};
window.SubirDocumentoModal = SubirDocumentoModal;

window.RevinSupabase = {
  getClient,
  loadDataFromSupabase,
  asegurarAcceso,
  signInEmailPassword,
  signOut,
  getSession,
  crearTarea,
  crearCliente,
  buscarDuplicados,
  errorAmable,
  usuarioActualId,
  firmarVisita,
  actualizarNotasVisita,
  completarDatosCliente,
  siguienteRef,
  crearInmueble,
  refEnCajon,
  cajonDeRef,
  cambiarCajonDeInmueble,
  CAJONES_REF,
  cargarInmuebleCrudo,
  actualizarInmueble,
  crearProyecto,
  asignarParcelaAInmueble,
  crearCaptacionDesdeParcela,
  crearHonorario,
  // Multi-tenant (Fase 0) — inyección centralizada de org_id
  conOrg,
  resolverOrgId,
  // Storage (Fase 0, criterio 3)
  subirDocumento,
  urlDocumento,
  descargarDocumento,
  sanearNombreFichero,
  fmtTamano,
  bucketDeTipo,
  DOC_TIPOS,
  // Motor de comisiones (Fases 4 y 5)
  actualizarComisionPct,
  asignarComercialProyecto,
  guardarPrevisionComision,
  borrarPrevisionComision,
  materializarComision,
  SupabaseLoginModal,
  signInWithGoogle,
  // helpers de display reutilizables en componentes
  ui: {
    clienteType: _clienteTypeUI,
    provType: _provTypeUI,
    figura: _figuraUI,
    motivacion: _motivacionUI,
    estado: _estadoUI,
    origin: _originUI,
    financing: _financingUI,
    fmtAgo: _fmtAgo,
  },
};
