// =========================================================================
// MAP VIEW
// =========================================================================

// Cartografía: tres servicios públicos y gratuitos. La atribución es
// obligatoria y va en cada source, no en un pie de página que se pueda olvidar.
const TESELAS_IGN =
  "https://www.ign.es/wmts/ign-base?service=WMTS&request=GetTile&version=1.0.0" +
  "&layer=IGNBaseTodo&style=default&tilematrixset=GoogleMapsCompatible" +
  "&format=image/jpeg&TileMatrix={z}&TileCol={x}&TileRow={y}";

const TESELAS_PNOA =
  "https://www.ign.es/wmts/pnoa-ma?service=WMTS&request=GetTile&version=1.0.0" +
  "&layer=OI.OrthoimageCoverage&style=default&tilematrixset=GoogleMapsCompatible" +
  "&format=image/jpeg&TileMatrix={z}&TileCol={x}&TileRow={y}";

// El Catastro no publica WMTS, sólo WMS: se le piden recortes por bbox y
// MapLibre los coloca como si fueran teselas. Por eso {bbox-epsg-3857}.
const TESELAS_CATASTRO =
  "https://ovc.catastro.meh.es/Cartografia/WMS/ServidorWMS.aspx?SERVICE=WMS" +
  "&VERSION=1.1.1&REQUEST=GetMap&LAYERS=Catastro&STYLES=&FORMAT=image/png" +
  "&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256";

const ATRIB_IGN = '<a href="https://www.ign.es" rel="noreferrer">IGN</a>';
const ATRIB_CAT =
  '<a href="https://www.catastro.minhap.es" rel="noreferrer">Dirección General del Catastro</a>';

// Sin fallos de tesela durante este rato, el aviso se retira solo.
const TREGUA = 5000;

// El OVC responde con Access-Control-Allow-Origin: * en los dos endpoints
// (comprobado el 27-ago-2026), asi que se llama directo desde el navegador y
// este CRM no necesita backend para esto.
const OVC_RCCOOR =
  "https://ovc.catastro.meh.es/ovcservweb/OVCSWLocalizacionRC/OVCCoordenadas.asmx/Consulta_RCCOOR";
const OVC_DNPRC =
  "https://ovc.catastro.meh.es/OVCServWeb/OVCWcfCallejero/COVCCallejero.svc/json/Consulta_DNPRC";

// Los errores del OVC vienen en MAYUSCULAS y en su jerga. Se traducen: quien
// pincha el mar no necesita leer "PARA ESAS COORDENADAS NO HAY REFERENCIA
// DISPONIBLE", necesita saber que ahi no hay parcela y por que puede pasar.
const ERRORES_OVC = {
  "16": "Ahí no hay ninguna parcela catastral. Suele ser suelo público, una carretera, agua o algo fuera de España.",
};

// Referencia catastral de 14 digitos a partir de una coordenada. Devuelve XML.
async function rcPorCoordenada(lng, lat) {
  const u = OVC_RCCOOR + "?SRS=EPSG:4326&Coordenada_X=" + lng + "&Coordenada_Y=" + lat;
  const xml = new DOMParser().parseFromString(await (await fetch(u)).text(), "text/xml");
  const err = xml.querySelector("err des");
  if (err) {
    const cod = xml.querySelector("err cod");
    throw new Error(ERRORES_OVC[cod && cod.textContent] || err.textContent);
  }
  // pc1/pc2 van anidados en coord > pc; querySelector los encuentra igual, y
  // en rustica valen igual: 23009A0 + 2500919 es una referencia buena.
  const pc1 = xml.querySelector("pc1"), pc2 = xml.querySelector("pc2");
  if (!pc1 || !pc2) throw new Error("Ahí no hay ninguna parcela catastral.");
  return pc1.textContent + pc2.textContent;
}

// Datos NO protegidos de una referencia. Ojo con las dos formas de respuesta:
// `bico.bi` cuando la parcela tiene una sola unidad y `lrcdnp.rcdnp[]` cuando
// es un edificio en division horizontal. Tratar solo la segunda deja fuera
// justo los casos limpios: casas y parcelas.
async function datosDeParcela(rc) {
  const d = (await (await fetch(OVC_DNPRC + "?Provincia=&Municipio=&RefCat=" + rc)).json())
    .consulta_dnprcResult;
  if (d.lerr) throw new Error(d.lerr[0].des || "El Catastro no devolvio datos.");

  // El municipio viene en dt.nm, en MAYUSCULAS y sin tildes ("JAEN"). Se
  // capitaliza para que la ficha creada no grite.
  const capitalizar = (t) => !t ? null : String(t).toLowerCase()
    .replace(/(^|\s|-)([a-záéíóúñ])/g, (m, a, b) => a + b.toUpperCase());

  const leer = (u, rcNodo) => {
    const dt = u.dt || {}, debi = u.debi || {};
    const urb = ((dt.locs || {}).lous || {}).lourb || {};
    const dir = urb.dir || {}, loint = urb.loint || {};
    return {
      rc20: ["pc1", "pc2", "car", "cc1", "cc2"].map((k) => rcNodo[k] || "").join(""),
      via: [dir.tv, dir.nv, dir.pnp].filter(Boolean).join(" "),
      planta: loint.pt, puerta: loint.pu, escalera: loint.es,
      uso: debi.luso,
      superficie: /^\d+$/.test(String(debi.sfc || "")) ? Number(debi.sfc) : null,
      ano: /^\d+$/.test(String(debi.ant || "")) ? Number(debi.ant) : null,
      municipio: capitalizar(dt.nm),
      provincia: capitalizar(dt.np),
      cp: (dt.locs && dt.locs.lous && dt.locs.lous.lourb && dt.locs.lous.lourb.dp) || null,
    };
  };

  if (d.bico) {
    const bi = d.bico.bi || {}, f = d.bico.finca || {};
    return {
      rc14: rc, unidades: [leer(bi, (bi.idbi || {}).rc || {})],
      finca: { tipo: f.ltp, suelo: (f.dff || {}).ss, url: (f.infgraf || {}).igraf },
      ldt: bi.ldt,
    };
  }
  let nodo = (d.lrcdnp || {}).rcdnp || [];
  if (!Array.isArray(nodo)) nodo = [nodo];
  return {
    rc14: rc,
    unidades: nodo.map((u) => leer(u, u.rc || {})),
    finca: { url: "https://www1.sedecatastro.gob.es/Cartografia/mapa.aspx?refcat=" + rc },
  };
}

/**
 * Cache de parcelas consultadas.
 *
 * Medido el 27-ago-2026: `Consulta_DNPRC` tarda 21,9 s para una parcela de 534
 * inmuebles, y el OVC NO cachea —la segunda llamada tardo otros 21,5 s—. Sin
 * esto, volver a pinchar la misma parcela vuelve a costar veinte segundos.
 *
 * En sesion y no en local a proposito: son datos de terceros que cambian, y
 * que caduquen al cerrar la pestana es exactamente la vida util que queremos.
 */
const cacheParcelas = new Map();

function deCache(rc) {
  if (cacheParcelas.has(rc)) return cacheParcelas.get(rc);
  try {
    const cru = sessionStorage.getItem("rv-parcela-" + rc);
    if (cru) { const d = JSON.parse(cru); cacheParcelas.set(rc, d); return d; }
  } catch (_) { /* sessionStorage puede fallar en privado o sin permiso */ }
  return null;
}

function aCache(rc, datos) {
  cacheParcelas.set(rc, datos);
  try { sessionStorage.setItem("rv-parcela-" + rc, JSON.stringify(datos)); }
  catch (_) { /* cuota llena: la copia en memoria basta */ }
}

/**
 * Sugerencias de direccion de Cartociudad (IGN). El buscador solo filtraba
 * NUESTRA cartera, asi que escribir una calle donde todavia no tenemos nada no
 * devolvia nada — justo al reves de lo que hace falta para prospectar.
 *
 * Devuelve JSONP (`callback([...])`) aunque se pida con fetch, asi que hay que
 * quitarle el envoltorio. Manda Access-Control-Allow-Origin: *.
 */
const CARTO_CANDIDATOS = "https://www.cartociudad.es/geocoder/api/geocoder/candidatesJsonp?q=";
const CARTO_BUSCAR = "https://www.cartociudad.es/geocoder/api/geocoder/findJsonp?q=";

function _desJsonp(t) {
  const limpio = t.trim().replace(/^[^(]*\(/, "").replace(/\)\s*;?\s*$/, "");
  try { return JSON.parse(limpio); } catch (_) { return null; }
}

async function sugerirDirecciones(q) {
  const d = _desJsonp(await (await fetch(CARTO_CANDIDATOS + encodeURIComponent(q))).text());
  if (!Array.isArray(d)) return [];
  return d
    .filter((c) => c && c.address && ["portal", "callejero", "municipio", "poblacion", "tipo_via"].includes(c.type))
    .slice(0, 6);
}

async function coordenadasDe(direccion) {
  const d = _desJsonp(await (await fetch(CARTO_BUSCAR + encodeURIComponent(direccion))).text());
  const c = Array.isArray(d) ? d[0] : d;
  return c && c.lat ? { lat: c.lat, lng: c.lng, tipo: c.type } : null;
}

const esExacta = (c) => c === "portal" || c === "manual";

/**
 * `estados` no es un ciclo, es un array de etiquetas que se combinan: existen
 * "Despublicado + En venta + Negociación" y "Alquiler por habitaciones +
 * Despublicado + En alquiler". Asi que disponible NO es un campo, es una regla:
 * tiene "En venta" o "En alquiler" y no tiene "Vendido".
 */
const claseEstado = (p) => {
  const e = p.estados || [];
  if (e.includes("Vendido")) return "vendido";
  if (e.includes("En venta") || e.includes("En alquiler")) return "disponible";
  return "otro";
};

const ETIQUETA_ESTADO = { disponible: "Disponible", vendido: "Vendido", otro: "Otros" };

const ETIQUETA_GEO = {
  portal: "Portal exacto",
  via: "Calle aproximada",
  municipio: "Sólo el municipio",
  manual: "Puesto a mano",
  sin_geocodificar: "Sin geocodificar",
};

// =========================================================================
// MAP VIEW
// =========================================================================

const MapView = ({ onCaptado } = {}) => {
  const { Icons, DATA, fmtEUR } = window;

  const contenedor = React.useRef(null);
  const mapa = React.useRef(null);
  const marcadores = React.useRef(new Map());
  const ultimoFallo = React.useRef(0);

  const [listo, setListo] = React.useState(false);
  const [teselas, setTeselas] = React.useState("cargando");
  const [capaBase, setCapaBase] = React.useState("mapa");
  const [verCatastro, setVerCatastro] = React.useState(true);
  const [seleccionado, setSeleccionado] = React.useState(null);
  const [apilados, setApilados] = React.useState(null);
  const [busqueda, setBusqueda] = React.useState("");
  const [sugerencias, setSugerencias] = React.useState([]);
  // Parcela consultada al pinchar el mapa: { estado, rc, datos, error }
  const [parcela, setParcela] = React.useState(null);
  const marcaParcela = React.useRef(null);
  const [filtroEstado, setFiltroEstado] = React.useState("todos");
  // Posicion propia. NO se guarda ni se comparte con nadie: vive en memoria
  // mientras la pestana este abierta. Sin tratamiento de datos de plantilla.
  const [miPos, setMiPos] = React.useState(null);
  // Estado, NO el ref: los refs no repintan. Se leia `vigilante.current` en el
  // render y funcionaba de rebote porque setMiPos forzaba el repintado; pero al
  // denegar el permiso el watch ya habia devuelto un id, asi que el boton decia
  // "Dejar de seguirme" mientras no seguia a nadie.
  const [siguiendo, setSiguiendo] = React.useState(false);
  const [version, setVersion] = React.useState(0);
  const vigilante = React.useRef(null);
  const marcaYo = React.useRef(null);

  // Sólo se pinta lo que tiene coordenada. Los demás se cuentan y se dicen.
  // El mapa enseña TODA la agencia. `INMUEBLES_MAPA` trae los 56 con SOLO las
  // columnas que se dibujan; el resto de pantallas siguen con la RLS por
  // cartera y sus 52. Sin vista (datos de prueba) cae a lo de siempre.
  const cartera = DATA.INMUEBLES_MAPA || DATA.INMUEBLES;
  const conCoords = React.useMemo(
    () => cartera.filter((p) => p.lat != null && p.lng != null),
    // `version` entra a proposito: asignar una parcela muta la ficha dentro de
    // window.DATA (que es como funciona el store de este CRM) y la identidad
    // del array no cambia, asi que sin esto el mapa no se enteraria.
    [cartera, version],
  );
  const sinCoords = cartera.length - conCoords.length;

  const porId = React.useMemo(
    () => new Map(conCoords.map((p) => [p.id, p])),
    [conCoords],
  );

  const visibles = React.useMemo(() => {
    const t = busqueda.trim().toLowerCase();
    return conCoords.filter((p) => {
      if (filtroEstado !== "todos" && claseEstado(p) !== filtroEstado) return false;
      if (!t) return true;
      return [p.ref, p.addr, p.town, p.refCatastral, p.refCatastralParcela]
        .filter(Boolean)
        .some((v) => String(v).toLowerCase().includes(t));
    });
  }, [conCoords, busqueda, filtroEstado]);

  // Un marcador por COORDENADA, no por inmueble: en la cartera real hay
  // portales con tres inmuebles y, uno encima de otro, sólo se puede pinchar
  // el último del DOM. Los demás quedan invisibles y el contador miente.
  const grupos = React.useMemo(() => {
    const g = new Map();
    visibles.forEach((p) => {
      const clave = p.lng + "," + p.lat;
      if (g.has(clave)) g.get(clave).push(p);
      else g.set(clave, [p]);
    });
    return g;
  }, [visibles]);

  // ---- sugerencias de dirección, con freno --------------------------------
  // 250 ms: escribir "Bernabé Soriano" son quince pulsaciones y no vamos a
  // pedirle quince respuestas al IGN. Y si el texto ya cambió, la respuesta que
  // llega tarde se descarta: si no, la última en llegar pisa a la buena.
  React.useEffect(() => {
    const q = busqueda.trim();
    if (q.length < 3) { setSugerencias([]); return; }
    let vigente = true;
    const t = setTimeout(async () => {
      try {
        const r = await sugerirDirecciones(q);
        if (vigente) setSugerencias(r);
      } catch (_) { if (vigente) setSugerencias([]); }
    }, 250);
    return () => { vigente = false; clearTimeout(t); };
  }, [busqueda]);

  const irADireccion = async (c) => {
    setSugerencias([]);
    const m = mapa.current;
    if (!m) return;
    const p = (c.lat && c.lng) ? { lat: c.lat, lng: c.lng } : await coordenadasDe(c.address);
    if (!p) return;
    m.easeTo({ center: [p.lng, p.lat], zoom: c.type === "portal" ? 18 : 15, duration: 700 });
  };

  // ---- construcción del mapa, una sola vez -------------------------------
  React.useEffect(() => {
    if (!contenedor.current || mapa.current) return;
    if (!window.maplibregl) return;

    const m = new window.maplibregl.Map({
      container: contenedor.current,
      style: {
        version: 8,
        sources: {
          // maxzoom 20 es el techo REAL del IGN, medido: z20 devuelve 200 y
          // z21/z22 devuelven 400. Sin el tope, al acercarse a una parcela
          // salían ráfagas de error y el aviso se encendía sobre un mapa sano.
          ign: { type: "raster", tiles: [TESELAS_IGN], tileSize: 256, maxzoom: 20, attribution: ATRIB_IGN },
          pnoa: { type: "raster", tiles: [TESELAS_PNOA], tileSize: 256, maxzoom: 20, attribution: ATRIB_IGN },
          catastro: { type: "raster", tiles: [TESELAS_CATASTRO], tileSize: 256, attribution: ATRIB_CAT },
        },
        layers: [
          { id: "base-mapa", type: "raster", source: "ign" },
          { id: "base-orto", type: "raster", source: "pnoa", layout: { visibility: "none" } },
          { id: "parcelario", type: "raster", source: "catastro", paint: { "raster-opacity": 0.75 } },
        ],
      },
      center: [-3.79, 37.77],
      zoom: 11,
      attributionControl: { compact: true },
    });

    m.addControl(new window.maplibregl.NavigationControl({ showCompass: false }), "top-right");
    m.addControl(new window.maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }));

    if (conCoords.length > 0) {
      const limites = new window.maplibregl.LngLatBounds();
      conCoords.forEach((p) => limites.extend([p.lng, p.lat]));
      m.fitBounds(limites, { padding: 60, maxZoom: 15, animate: false });
    }

    // Clic en cualquier punto del mapa: se pregunta al Catastro que parcela hay
    // ahi. Los marcadores de la cartera hacen stopPropagation, asi que pinchar
    // un inmueble NO dispara esto.
    m.on("click", async (e) => {
      const { lng, lat } = e.lngLat;
      setSeleccionado(null);
      setApilados(null);
      setParcela({ estado: "cargando" });

      if (marcaParcela.current) marcaParcela.current.remove();
      const punto = document.createElement("div");
      punto.className = "rv-parcela-punto";
      marcaParcela.current = new window.maplibregl.Marker({ element: punto })
        .setLngLat([lng, lat]).addTo(m);

      // Dos fases: la referencia llega en ~400 ms y se ensena YA. El desglose
      // puede tardar veinte segundos y no hay razon para tener a alguien
      // mirando una pantalla vacia mientras tanto.
      try {
        const rc = await rcPorCoordenada(lng.toFixed(6), lat.toFixed(6));

        const guardada = deCache(rc);
        if (guardada) { setParcela({ estado: "ok", rc, datos: guardada, lng, lat }); return; }

        setParcela({ estado: "desglosando", rc, desde: Date.now(), lng, lat });
        const datos = await datosDeParcela(rc);
        aCache(rc, datos);
        setParcela({ estado: "ok", rc, datos, lng, lat });
      } catch (err) {
        setParcela({ estado: "error", error: err.message });
      }
    });

    m.on("load", () => setListo(true));
    m.on("error", (ev) => {
      if (ev && ev.sourceId) { ultimoFallo.current = Date.now(); setTeselas("fallo"); }
    });
    // El aviso no es un pestillo: un 5xx puntual del IGN o un corte de datos en
    // la calle no puede dejar el cartel encendido el resto de la sesión.
    m.on("idle", () => setTeselas(Date.now() - ultimoFallo.current > TREGUA ? "ok" : "fallo"));

    // MapLibre sólo escucha el resize de la VENTANA. Si el contenedor cambia
    // de tamaño sin que la ventana lo haga, el canvas se queda con la medida
    // vieja y el mapa ocupa media caja.
    const obs = new ResizeObserver(() => m.resize());
    obs.observe(contenedor.current);

    mapa.current = m;
    return () => { obs.disconnect(); m.remove(); mapa.current = null; };
  }, []);

  // ---- marcadores --------------------------------------------------------
  React.useEffect(() => {
    const m = mapa.current;
    if (!m) return;
    const creados = new Map();

    grupos.forEach((grupo, clave) => {
      const primero = grupo[0];
      const varios = grupo.length > 1;
      // Exacto sólo si TODOS lo son: basta uno aproximado para no prometer.
      const exacto = grupo.every((p) => esExacta(p.geoConfianza));
      const lado = varios ? 20 : 14;
      // El COLOR dice el estado comercial; la OPACIDAD, si la coordenada es de
      // fiar. Son dos preguntas distintas y merecen dos canales distintos.
      const clases = new Set(grupo.map(claseEstado));
      const estado = clases.size === 1 ? [...clases][0] : "otro";

      const el = document.createElement("button");
      el.type = "button";
      el.className = "rv-punto " + estado;
      el.style.width = lado + "px";
      el.style.height = lado + "px";
      el.textContent = varios ? String(grupo.length) : "";
      el.setAttribute("aria-label", varios
        ? grupo.length + " inmuebles en esta ubicación: " + grupo.map((p) => p.ref).join(", ")
        : primero.ref + " · " + primero.addr);
      el.title = varios ? grupo.length + " inmuebles aquí"
        : primero.ref + " · " + ETIQUETA_ESTADO[estado] + (exacto ? "" : " · ubicación aproximada");
      el.addEventListener("click", (e) => {
        e.stopPropagation();
        if (varios) { setSeleccionado(null); setApilados(grupo.map((p) => p.id)); }
        else { setApilados(null); setSeleccionado(primero.id); }
      });

      creados.set(clave, new window.maplibregl.Marker({ element: el, opacity: exacto ? "1" : "0.55" })
        .setLngLat([primero.lng, primero.lat]).addTo(m));
    });

    marcadores.current = creados;
    return () => { creados.forEach((mk) => mk.remove()); marcadores.current = new Map(); };
  }, [grupos]);

  // ---- capas -------------------------------------------------------------
  React.useEffect(() => {
    const m = mapa.current;
    if (!listo || !m) return;
    m.setLayoutProperty("base-mapa", "visibility", capaBase === "mapa" ? "visible" : "none");
    m.setLayoutProperty("base-orto", "visibility", capaBase === "orto" ? "visible" : "none");
  }, [capaBase, listo]);

  React.useEffect(() => {
    const m = mapa.current;
    if (!listo || !m) return;
    m.setLayoutProperty("parcelario", "visibility", verCatastro ? "visible" : "none");
    // Sobre fondo neutro el parcelario va a plena opacidad: si se ha elegido
    // planimetria es para leer linderos, y un 75% ahi solo lo ensucia. Encima
    // de una base sigue translucido para no tapar la calle ni el tejado.
    m.setPaintProperty("parcelario", "raster-opacity", capaBase === "ninguna" ? 1 : 0.75);
  }, [verCatastro, capaBase, listo]);

  // ---- foco --------------------------------------------------------------
  const foco = React.useMemo(() => {
    if (seleccionado) return porId.get(seleccionado) || null;
    if (apilados && apilados[0]) return porId.get(apilados[0]) || null;
    return null;
  }, [seleccionado, apilados, porId]);

  React.useEffect(() => {
    const clave = foco ? foco.lng + "," + foco.lat : null;
    marcadores.current.forEach((mk, k) => {
      mk.getElement().classList.toggle("activo", k === clave);
    });
  }, [foco, grupos]);

  // Separado del resaltado: `grupos` cambia en cada tecla del buscador y, en un
  // solo efecto, el mapa se recentraba letra a letra mientras escribías.
  React.useEffect(() => {
    const m = mapa.current;
    if (!m || !foco) return;
    m.easeTo({ center: [foco.lng, foco.lat], zoom: Math.max(m.getZoom(), 17), duration: 600 });
  }, [foco]);

  // Lo que el filtro excluye no se queda abierto en el panel.
  React.useEffect(() => {
    const vivos = new Set(visibles.map((p) => p.id));
    if (seleccionado && !vivos.has(seleccionado)) setSeleccionado(null);
    if (apilados && !apilados.some((id) => vivos.has(id))) setApilados(null);
  }, [visibles, seleccionado, apilados]);

  /**
   * Posicion propia, en memoria y nada mas: no se guarda ni se envia a ningun
   * sitio. Es para prospectar de calle sin ir tecleando direcciones.
   *
   * El permiso se puede denegar y eso NO es un error: se dice y se sigue. Un
   * mapa que se rompe porque alguien dijo que no a la ubicacion esta mal hecho.
   */
  const alternarSeguimiento = () => {
    if (vigilante.current !== null) {
      navigator.geolocation.clearWatch(vigilante.current);
      vigilante.current = null;
      setSiguiendo(false);
      if (marcaYo.current) { marcaYo.current.remove(); marcaYo.current = null; }
      setMiPos(null);
      return;
    }
    if (!navigator.geolocation) {
      setMiPos({ estado: "no_disponible" });
      return;
    }
    setMiPos({ estado: "buscando" });
    setSiguiendo(true);
    vigilante.current = navigator.geolocation.watchPosition(
      (pos) => {
        const { longitude: lng, latitude: lat, accuracy } = pos.coords;
        setMiPos({ estado: "ok", lng, lat, precision: Math.round(accuracy) });
        const m = mapa.current;
        if (!m) return;
        if (!marcaYo.current) {
          const el = document.createElement("div");
          el.className = "rv-yo";
          marcaYo.current = new window.maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(m);
          m.easeTo({ center: [lng, lat], zoom: Math.max(m.getZoom(), 17), duration: 800 });
        } else {
          marcaYo.current.setLngLat([lng, lat]);
        }
        marcaYo.current.getElement().title = "Estás aquí (±" + Math.round(accuracy) + " m)";
      },
      (err) => {
        // Un watch que ha fallado no esta siguiendo nada: se cierra y el boton
        // vuelve a ofrecer reintentarlo, en vez de mentir.
        if (vigilante.current !== null) { navigator.geolocation.clearWatch(vigilante.current); vigilante.current = null; }
        setSiguiendo(false);
        setMiPos({ estado: err.code === 1 ? "denegado" : "fallo", mensaje: err.message });
      },
      { enableHighAccuracy: true, timeout: 15000, maximumAge: 5000 },
    );
  };

  // Consulta el Catastro justo donde estas: el atajo de prospeccion.
  const queHayAqui = async () => {
    if (!miPos || miPos.estado !== "ok") return;
    setSeleccionado(null); setApilados(null);
    const { lng, lat } = miPos;   // la coordenada viaja con la parcela
    setParcela({ estado: "cargando" });
    try {
      const rc = await rcPorCoordenada(lng.toFixed(6), lat.toFixed(6));
      const guardada = deCache(rc);
      if (guardada) { setParcela({ estado: "ok", rc, datos: guardada, lng, lat }); return; }
      setParcela({ estado: "desglosando", rc, desde: Date.now(), lng, lat });
      const datos = await datosDeParcela(rc);
      aCache(rc, datos);
      setParcela({ estado: "ok", rc, datos, lng, lat });
    } catch (err) {
      setParcela({ estado: "error", error: err.message });
    }
  };

  // Al desmontar, se deja de vigilar. Un watchPosition huerfano sigue
  // despertando el GPS del movil aunque nadie mire el mapa.
  React.useEffect(() => () => {
    if (vigilante.current !== null) navigator.geolocation.clearWatch(vigilante.current);
  }, []);

  /**
   * Ata la parcela pinchada a un inmueble nuestro. La coordenada que se guarda
   * es la del punto pinchado, no el centroide de la parcela: es donde la
   * persona ha dicho "es aqui".
   */
  const asignarParcela = async (inmuebleId) => {
    // La coordenada sale del propio estado de la parcela y NO del marcador:
    // ese marcador solo existe si se llego pinchando el mapa. Llegando por
    // "que hay donde estoy" era null y el boton no hacia nada, en silencio.
    if (!parcela || parcela.estado !== "ok" || parcela.lng == null) return;
    const { lng, lat } = parcela;

    // Sin sesion la RLS rechaza la escritura, y el error que llega es generico.
    // Decir "error tecnico" cuando la causa es "estas en datos de prueba" es
    // justo la clase de mensaje inutil que este front evita en todas partes.
    try {
      const sesion = await window.RevinSupabase.getSession();
      if (!sesion) {
        setParcela((prev) => ({
          ...prev, avisoOk: false,
          aviso: "Estás en datos de prueba, así que no hay dónde guardar. Pulsa «Conectar a Supabase» arriba y vuelve a intentarlo.",
        }));
        return;
      }
    } catch (_) { /* si ni siquiera se puede preguntar, que lo diga el catch de abajo */ }

    try {
      await window.RevinSupabase.asignarParcelaAInmueble(inmuebleId, {
        rc14: parcela.rc, lng, lat, datos: parcela.datos,
      });
      // Refleja el cambio ya, sin esperar a recargar toda la aplicacion.
      const p = cartera.find((x) => x.id === inmuebleId);
      if (p) {
        p.lat = lat; p.lng = lng;
        p.geoConfianza = "manual";
        p.refCatastralParcela = parcela.rc;
      }
      setVersion((v) => v + 1);
      cerrarParcela();
      setSeleccionado(inmuebleId);
    } catch (e) {
      const msg = window.RevinSupabase.errorAmable
        ? window.RevinSupabase.errorAmable(e)
        : e.message;
      setParcela((prev) => ({ ...prev, avisoOk: false, aviso: String(msg) }));
    }
  };

  /**
   * Crea una captación desde una unidad de la parcela. La comprobación de
   * duplicado va también en servidor: entre que se abre la ficha y se pulsa,
   * otro comercial puede haberla creado.
   */
  const captarUnidad = async (u) => {
    // La coordenada sale del propio estado de la parcela y NO del marcador:
    // ese marcador solo existe si se llego pinchando el mapa. Llegando por
    // "que hay donde estoy" era null y el boton no hacia nada, en silencio.
    if (!parcela || parcela.estado !== "ok" || parcela.lng == null) return;
    const { lng, lat } = parcela;

    try {
      const sesion = await window.RevinSupabase.getSession();
      if (!sesion) {
        setParcela((prev) => ({ ...prev, avisoOk: false, aviso: "Estás en datos de prueba, así que no hay dónde crearla. Pulsa «Conectar a Supabase»." }));
        return;
      }
    } catch (_) { /* que lo diga el catch de abajo */ }

    const faseCaptacion = (DATA.PHASES || []).find((f) => f.key === "captacion");
    try {
      const r = await window.RevinSupabase.crearCaptacionDesdeParcela({
        rc14: parcela.rc,
        // u es null cuando se capta la parcela ENTERA, no una unidad suelta.
        rc20: u ? u.rc20 : null,
        unidad: u || null,
        lng, lat,
        datos: parcela.datos,
        faseCaptacionId: faseCaptacion && faseCaptacion.id,
      });
      if (!r.creado) {
        setParcela((prev) => ({ ...prev, avisoOk: true, aviso: "Ya existía en la cartera como " + r.inmueble.ref + ". No se ha creado otra." }));
        return;
      }
      // Se sale del mapa a la ficha del inmueble. El Catastro solo da
      // direccion, superficie, año y uso: el precio, el propietario y el
      // estado hay que escribirlos, y hasta hoy no habia donde.
      setParcela((prev) => ({ ...prev, avisoOk: true, aviso: "Creada la captación " + r.inmueble.ref + ". Abriendo su ficha…" }));
      if (onCaptado) { await onCaptado(r.inmueble); return; }
      setParcela((prev) => ({ ...prev, avisoOk: true, aviso: "Creada la captación " + r.inmueble.ref + ". Recarga para verla en el mapa." }));
    } catch (e) {
      const msg = window.RevinSupabase.errorAmable ? window.RevinSupabase.errorAmable(e) : e.message;
      setParcela((prev) => ({ ...prev, avisoOk: false, aviso: String(msg) }));
    }
  };

  const cerrarParcela = () => {
    setParcela(null);
    if (marcaParcela.current) { marcaParcela.current.remove(); marcaParcela.current = null; }
  };

  const ficha = seleccionado ? porId.get(seleccionado) : null;
  const lista = apilados
    ? apilados.map((id) => porId.get(id)).filter(Boolean)
    : visibles;
  const aproximados = visibles.filter((p) => !esExacta(p.geoConfianza)).length;

  return (
    <div className="map-layout">
      <div className="map-canvas">
        <div className="map-overlay">
          <div className="rv-capas">
            <button type="button" aria-pressed={capaBase === "mapa"} onClick={() => setCapaBase("mapa")}>Mapa</button>
            <button type="button" aria-pressed={capaBase === "orto"} onClick={() => setCapaBase("orto")}>Ortofoto</button>
            {/* Planimetria: el parcelario solo, sobre fondo neutro. Elegirla
                enciende el Catastro, porque sin el la pantalla se queda vacia
                y el usuario no tendria forma de saber por que. */}
            <button
              type="button"
              aria-pressed={capaBase === "ninguna"}
              onClick={() => { setCapaBase("ninguna"); setVerCatastro(true); }}
            >Planimetría</button>
            <label>
              <input type="checkbox" checked={verCatastro} onChange={(e) => setVerCatastro(e.target.checked)}/>
              Catastro
            </label>
          </div>

          <div className="map-search" style={{position: "relative"}}>
            <Icons.Search/>
            <input
              placeholder="Buscar ref, dirección o referencia catastral…"
              value={busqueda}
              onChange={(e) => setBusqueda(e.target.value)}
              onBlur={() => setTimeout(() => setSugerencias([]), 150)}
            />
            {sugerencias.length > 0 && (
              <div className="rv-sugerencias">
                <p className="rv-sug-eyebrow">Direcciones · IGN</p>
                {sugerencias.map((c, i) => (
                  <button key={c.id || i} type="button" onMouseDown={() => irADireccion(c)}>
                    <span className="d">{c.address}</span>
                    <span className="m">{[c.muni, c.province].filter(Boolean).join(" · ")}</span>
                  </button>
                ))}
              </div>
            )}
            <Pill tone="neutral">
              {visibles.length < conCoords.length
                ? visibles.length + " de " + conCoords.length
                : conCoords.length + " inmuebles"}
            </Pill>
          </div>

          <div style={{display: "flex", gap: 8, alignItems: "center"}}>
            <select
              className="rv-filtro"
              value={filtroEstado}
              onChange={(e) => setFiltroEstado(e.target.value)}
              aria-label="Filtrar por estado"
            >
              <option value="todos">Todos los estados</option>
              <option value="disponible">Solo disponibles</option>
              <option value="vendido">Solo vendidos</option>
              <option value="otro">Otros</option>
            </select>

            {miPos && miPos.estado === "ok" && (
              <button className="map-near" onClick={queHayAqui}>
                <Icons.Search/> ¿Qué hay aquí?
              </button>
            )}

            <button
              className="map-near"
              onClick={alternarSeguimiento}
              style={siguiendo ? {background: "var(--revin-black)"} : undefined}
            >
              <Icons.Crosshair/> {siguiendo ? "Dejar de seguirme" : "Dónde estoy"}
            </button>
          </div>
        </div>

        <div ref={contenedor} style={{position: "absolute", inset: 0}}/>

        {miPos && miPos.estado !== "ok" && (
          <p className="rv-aviso-mapa" role="status" style={{left: "auto", right: 16}}>
            {miPos.estado === "buscando" ? "Buscando tu posición…"
              : miPos.estado === "denegado"
                ? "No has dado permiso de ubicación. El mapa funciona igual; solo pierdes el atajo de ver dónde estás."
                : miPos.estado === "no_disponible"
                  ? "Este navegador no da ubicación."
                  : "No se ha podido obtener la posición: " + (miPos.mensaje || "")}
          </p>
        )}

        {capaBase === "ninguna" && !verCatastro && (
          <p className="rv-aviso-mapa" role="status">
            No hay ninguna capa encendida: el mapa está en blanco a propósito.
            Marca Catastro o elige Mapa u Ortofoto.
          </p>
        )}

        {DATA.MAPA_DEGRADADO && (
          <p className="rv-aviso-mapa" role="status" style={{bottom: "auto", top: 16, left: "50%", transform: "translateX(-50%)"}}>
            No se pudo cargar la vista completa de la agencia: estás viendo solo
            tu cartera. Recarga; si sigue, avisa.
          </p>
        )}

        {/* Excluyente con el aviso de arriba: sin ninguna capa encendida no hay
            nada que cargar, y enseñar "cargando cartografía" al lado de "no hay
            ninguna capa" es decir dos cosas contrarias sobre lo mismo. */}
        {teselas !== "ok" && !(capaBase === "ninguna" && !verCatastro) && (
          <p className="rv-aviso-mapa" role="status">
            {teselas === "cargando"
              ? "Cargando cartografía del IGN y del Catastro…"
              : "Algunas teselas no llegan. El IGN o el Catastro están lentos o caídos: el mapa puede verse incompleto."}
          </p>
        )}
      </div>

      <div className="map-side">
        <div className="map-side-h">
          <h3>{parcela ? "Parcela del Catastro" : ficha ? ficha.ref : apilados ? "Mismo portal" : "Inmuebles en mapa"}</h3>
          <div className="sub">
            {parcela
              ? (parcela.rc || "Consultando…")
              : ficha
              ? ficha.addr
              : apilados
                ? lista.length + " inmuebles comparten esta coordenada"
                : visibles.length + " en el mapa" + (sinCoords > 0 ? " · " + sinCoords + " sin ubicar" : "")}
          </div>
          {!ficha && !apilados && !parcela && (
            <div className="sub" style={{marginTop: 6, display: "flex", flexWrap: "wrap", gap: 8}}>
              <span><span className="rv-clave disponible"/> Disponible</span>
              <span><span className="rv-clave vendido"/> Vendido</span>
              <span><span className="rv-clave otro"/> Otros</span>
              {aproximados > 0 && <span>· {aproximados} translúcidos = ubicación aproximada</span>}
            </div>
          )}
          {(ficha || apilados) && (
            <button
              className="map-row"
              style={{padding: "6px 0 0", border: "none", background: "none", fontSize: 11, color: "var(--fg-3)"}}
              onClick={() => { setSeleccionado(null); if (!ficha) setApilados(null); }}
            >← Volver</button>
          )}
        </div>

        {parcela ? (
          <FichaParcela
            parcela={parcela}
            alCerrar={cerrarParcela}
            candidatos={cartera}
            alAsignar={asignarParcela}
            alCaptar={captarUnidad}
          />
        ) :
         ficha ? <FichaMapa p={ficha} fmtEUR={fmtEUR}/> : (
          <div className="map-rows">
            {lista.map((p) => (
              <div className="map-row" key={p.id} onClick={() => setSeleccionado(p.id)}>
                <div className="addr">{p.addr}</div>
                <div className="meta">
                  <Pill tone="neutral">{p.type}</Pill>
                  <span>{p.town}</span>
                  {!esExacta(p.geoConfianza) && <Pill tone="warning">aprox</Pill>}
                  {p.price > 0 && <span>· {fmtEUR(p.price)}</span>}
                </div>
              </div>
            ))}
            {lista.length === 0 && (
              <div style={{padding: "24px 16px", textAlign: "center", fontSize: 12, color: "var(--fg-3)"}}>
                Ningún inmueble coincide con la búsqueda.
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  );
};

// Ficha de una parcela pinchada en el mapa. SOLO datos no protegidos: aqui no
// hay titularidad ni valor catastral, que exigen certificado digital e interes
// legitimo acreditado y no se pueden pedir desde una pantalla como esta.
/**
 * Una unidad de la parcela, con la acción de captarla.
 *
 * Se llama CAPTACIÓN y no "lead" a propósito: en este CRM un lead es una
 * PERSONA (la sección se llama Clientes/Leads) y esto es un edificio. Y
 * "Captación de inmueble" es literalmente el nombre de la fase 1 del pipeline,
 * así que el botón no inventa vocabulario: usa el que ya existe y dice dónde
 * va a aparecer lo creado.
 */
const UnidadConCaptacion = ({ u, rc14, ya, captando, alCaptar, unica }) => (
  <div style={{padding: "8px 0", borderBottom: unica ? "none" : "1px solid var(--border-3)"}}>
    <div><code className="r-doc-code">{u.rc20}</code></div>
    <div style={{fontSize: 11, color: "var(--fg-2)", marginTop: 3}}>
      {unica
        ? ([u.uso, u.superficie != null && u.superficie + " m²", u.ano].filter(Boolean).join(" · ") || "Sin división horizontal")
        : <>
            {u.puerta ? "Puerta " + u.puerta : "Sin puerta"} ·{" "}
            {[u.uso, u.superficie != null && u.superficie + " m²", u.ano].filter(Boolean).join(" · ")}
          </>}
    </div>

    {ya && ya.unidad ? (
      <p className="rv-nota" style={{marginTop: 6}}>
        Ya está en la cartera como <strong>{ya.unidad.ref}</strong>. Un inmueble existe
        una sola vez, así que no se crea otro.
      </p>
    ) : (
      <button
        className="map-near"
        style={{marginTop: 6, padding: "6px 12px", fontSize: 11}}
        disabled={captando}
        onClick={alCaptar}
      >
        {captando ? "Creando…" : "Crear captación"}
      </button>
    )}

    {ya && !ya.unidad && ya.parcela && (
      <p className="rv-nota" style={{marginTop: 4}}>
        Ojo: de esta parcela ya tienes <strong>{ya.parcela.ref}</strong>, sin unidad
        concreta. Puede que sea éste.
      </p>
    )}
  </div>
);

// Las plantas del Catastro son cadenas: "-1", "00", "01", "SM", "EN"… Se
// ordenan por numero cuando lo son, y el resto va detras por alfabeto.
const ordenPlanta = (a, b) => {
  const na = Number(a), nb = Number(b);
  const va = Number.isFinite(na), vb = Number.isFinite(nb);
  if (va && vb) return na - nb;
  if (va) return -1;
  if (vb) return 1;
  return String(a).localeCompare(String(b));
};

const etiquetaPlanta = (pl) => {
  if (pl == null || pl === "") return "Sin planta";
  const n = Number(pl);
  if (n === 0) return "Bajo";
  if (Number.isFinite(n) && n < 0) return "Sótano " + Math.abs(n);
  return "Planta " + (Number.isFinite(n) ? n : pl);
};

/**
 * Ficha de una parcela pinchada en el mapa.
 *
 * La lista plana no servia: hay parcelas de 534 inmuebles (medido en
 * 0620901VG3802S) y volcarlos todos es ilegible. Se navega por niveles:
 * uso → dirección → escalera → planta → puerta.
 *
 * El nivel de escalera SOLO aparece si la dirección tiene más de una. En esa
 * parcela solo 2 de 11 direcciones la necesitan; forzarla siempre seria un
 * clic vacio en las otras nueve.
 *
 * SOLO datos no protegidos: ni titularidad ni valor catastral, que exigen
 * certificado digital e interés legítimo acreditado.
 */
const FichaParcela = ({ parcela, alCerrar, candidatos, alAsignar, alCaptar }) => {
  /**
   * El aviso de resultado vive al final de la ficha, debajo de todo.
   *
   * Medido el 28-ago-2026 haciendo de comercial: en el movil la hoja mide
   * 618 px dentro de una ventana de 443, con scrollTop en 0. O sea que
   * "Creada la captacion REF-041" aparecia 175 px POR DEBAJO del pliegue y
   * nadie lo subia. Desde la calle: pulsas, no ves nada, vuelves a pulsar.
   * Y el silencio era igual de mudo cuando salia bien que cuando fallaba.
   */
  const refAviso = React.useRef(null);
  React.useEffect(() => {
    if (!parcela.aviso || !refAviso.current) return;
    // Ni "smooth" ni sincrono. El aviso aparece a la vez que se pide el
    // desplazamiento, asi que la hoja crece bajo los pies de la animacion y
    // esta se queda a medias: medido, paraba en 86 px de los 137 que hacian
    // falta, y el aviso seguia sin verse. Un fotograma de espera y salto seco.
    const id = requestAnimationFrame(() => {
      if (refAviso.current) refAviso.current.scrollIntoView({ block: "nearest" });
    });
    return () => cancelAnimationFrame(id);
  }, [parcela.aviso]);

  // Un inmueble existe UNA SOLA VEZ (CLAUDE.md §1). Antes de ofrecer captar,
  // se mira si ya está: por unidad de 20 dígitos, o por parcela si no la hay.
  /**
   * Devuelve { unidad, parcela }.
   *
   * Hay que distinguirlas: 31 de los 56 inmuebles tienen SOLO la referencia de
   * parcela, sin unidad. Comparando unicamente la de 20 digitos, pinchar un
   * bloque que ya tenemos y bajar a un piso ofrecia crear un segundo registro
   * del mismo edificio.
   *
   * `unidad` es coincidencia exacta y bloquea la captacion. `parcela` no la
   * bloquea —captar el 3ºB de un edificio del que tenemos el 1ºA es legitimo—
   * pero se avisa, porque casi siempre significa que ya está y sin detallar.
   */
  const yaEnCartera = React.useCallback((rc20, rc14) => {
    const lista = candidatos || [];
    return {
      unidad: (rc20 && lista.find((p) => p.refCatastral === rc20)) || null,
      parcela: (rc14 && lista.find((p) => p.refCatastralParcela === rc14 && !p.refCatastral)) || null,
    };
  }, [candidatos]);
  const [captando, setCaptando] = React.useState(null);
  const [uso, setUso] = React.useState("todos");
  const [dir, setDir] = React.useState(null);
  const [esc, setEsc] = React.useState(null);
  const [planta, setPlanta] = React.useState(null);
  const [elegido, setElegido] = React.useState("");
  const [guardando, setGuardando] = React.useState(false);
  const [segundos, setSegundos] = React.useState(0);

  // Reloj del desglose: si va a tardar veinte segundos, que se vea que avanza.
  React.useEffect(() => {
    if (!parcela || parcela.estado !== "desglosando") return;
    const t = setInterval(() => setSegundos(Math.round((Date.now() - parcela.desde) / 1000)), 500);
    return () => clearInterval(t);
  }, [parcela && parcela.estado, parcela && parcela.desde]);

  const unidades = (parcela.datos && parcela.datos.unidades) || [];

  // Usos con su cuenta, sacados del dato y no de una lista fija: en esa parcela
  // 358 de 534 son garajes, asi que filtrar por uso quita mas ruido que nada.
  const usos = React.useMemo(() => {
    const c = new Map();
    unidades.forEach((u) => c.set(u.uso || "Sin uso", (c.get(u.uso || "Sin uso") || 0) + 1));
    return [...c.entries()].sort((a, b) => b[1] - a[1]);
  }, [unidades]);

  const filtradas = React.useMemo(
    () => (uso === "todos" ? unidades : unidades.filter((u) => (u.uso || "Sin uso") === uso)),
    [unidades, uso],
  );

  const porDir = React.useMemo(() => {
    const m = new Map();
    filtradas.forEach((u) => {
      const k = u.via || "Sin dirección";
      if (!m.has(k)) m.set(k, []);
      m.get(k).push(u);
    });
    return [...m.entries()].sort((a, b) => b[1].length - a[1].length);
  }, [filtradas]);

  const enDir = React.useMemo(
    () => (dir ? (porDir.find(([k]) => k === dir) || [null, []])[1] : []),
    [porDir, dir],
  );

  const escaleras = React.useMemo(() => {
    const c = new Map();
    enDir.forEach((u) => { const k = u.escalera || "—"; c.set(k, (c.get(k) || 0) + 1); });
    return [...c.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])));
  }, [enDir]);

  // Aqui esta la clave: si solo hay una escalera, ese nivel NO existe.
  const hacenFaltaEscaleras = escaleras.length > 1;
  const enEsc = React.useMemo(
    () => (hacenFaltaEscaleras && esc ? enDir.filter((u) => (u.escalera || "—") === esc) : enDir),
    [enDir, esc, hacenFaltaEscaleras],
  );

  const plantas = React.useMemo(() => {
    const c = new Map();
    enEsc.forEach((u) => { const k = u.planta || ""; c.set(k, (c.get(k) || 0) + 1); });
    return [...c.entries()].sort((a, b) => ordenPlanta(a[0], b[0]));
  }, [enEsc]);

  const puertas = React.useMemo(
    () => (planta != null ? enEsc.filter((u) => (u.planta || "") === planta) : []),
    [enEsc, planta],
  );

  const volverA = (nivel) => {
    if (nivel <= 0) { setDir(null); setEsc(null); setPlanta(null); }
    else if (nivel === 1) { setEsc(null); setPlanta(null); }
    else if (nivel === 2) setPlanta(null);
  };

  // ---------- estados que no son "ya tengo el desglose" ----------
  if (parcela.estado === "cargando") {
    return <div style={{padding: 16, fontSize: 12, color: "var(--fg-3)"}}>Localizando la parcela…</div>;
  }

  if (parcela.estado === "error") {
    return (
      <div className="rv-cat" style={{borderTop: "none"}}>
        <p className="rv-nota" style={{marginTop: 0}}>{parcela.error}</p>
        <button className="map-near" style={{marginTop: 10}} onClick={alCerrar}>Cerrar</button>
      </div>
    );
  }

  if (parcela.estado === "desglosando") {
    return (
      <div className="rv-cat" style={{borderTop: "none"}}>
        <div className="fila"><dt>Referencia</dt><dd><code>{parcela.rc}</code></dd></div>
        <p className="rv-nota">
          Pidiendo el desglose al Catastro… {segundos > 0 && <strong>{segundos} s</strong>}
        </p>
        {segundos >= 6 && (
          <p className="rv-nota">
            Está tardando porque el servicio del Catastro es lento con las parcelas
            grandes: una de 534 inmuebles tarda unos 20 s y no cachea. La próxima vez
            que pinches esta misma parcela saldrá al instante.
          </p>
        )}
        <button className="map-near" style={{marginTop: 8, background: "var(--fg-3)"}} onClick={alCerrar}>Cancelar</button>
      </div>
    );
  }

  const d = parcela.datos || {};
  const orden = (candidatos || []).slice().sort((a, b) => {
    const peso = (p) => (p.lat == null ? 0 : esExacta(p.geoConfianza) ? 2 : 1);
    return peso(a) - peso(b) || String(a.ref).localeCompare(String(b.ref));
  });

  return (
    <div style={{overflow: "auto", flex: 1}}>
      <div className="rv-cat" style={{borderTop: "none"}}>
        <div className="fila"><dt>Referencia</dt><dd><code>{d.rc14}</code></dd></div>
        {d.ldt && <div className="fila"><dt>Localización</dt><dd style={{fontSize: 11}}>{d.ldt}</dd></div>}
        {d.finca && d.finca.tipo && <div className="fila"><dt>Tipo de finca</dt><dd style={{fontSize: 11}}>{d.finca.tipo}</dd></div>}
        {d.finca && d.finca.suelo && <div className="fila"><dt>Suelo</dt><dd>{d.finca.suelo} m²</dd></div>}
        <div className="fila"><dt>Inmuebles</dt><dd>{unidades.length}</dd></div>
      </div>

      {/* ---------- navegacion por niveles ---------- */}
      {unidades.length > 1 && (
        <div className="rv-cat">
          {usos.length > 1 && (
            <select
              className="rv-filtro" style={{width: "100%", marginBottom: 8}}
              value={uso}
              onChange={(e) => { setUso(e.target.value); volverA(0); }}
              aria-label="Filtrar por uso"
            >
              <option value="todos">Todos los usos ({unidades.length})</option>
              {usos.map(([u, n]) => <option key={u} value={u}>{u} ({n})</option>)}
            </select>
          )}

          {(dir || planta != null) && (
            <p className="rv-miga">
              <button type="button" onClick={() => volverA(0)}>Toda la parcela</button>
              {dir && <> › <button type="button" onClick={() => volverA(1)}>{dir}</button></>}
              {hacenFaltaEscaleras && esc && <> › <button type="button" onClick={() => volverA(2)}>Esc. {esc}</button></>}
              {planta != null && <> › {etiquetaPlanta(planta)}</>}
            </p>
          )}

          {!dir ? (
            <div className="rv-niveles">
              {porDir.map(([k, us]) => (
                <button key={k} type="button" onClick={() => setDir(k)}>
                  <span>{k}</span><span className="n">{us.length}</span>
                </button>
              ))}
            </div>
          ) : hacenFaltaEscaleras && !esc ? (
            <div className="rv-niveles">
              {escaleras.map(([k, n]) => (
                <button key={k} type="button" onClick={() => setEsc(k)}>
                  <span>Escalera {k}</span><span className="n">{n}</span>
                </button>
              ))}
            </div>
          ) : planta == null ? (
            <div className="rv-niveles">
              {plantas.map(([k, n]) => (
                <button key={k} type="button" onClick={() => setPlanta(k)}>
                  <span>{etiquetaPlanta(k)}</span><span className="n">{n}</span>
                </button>
              ))}
            </div>
          ) : (
            <div>
              {puertas.map((u) => (
                <UnidadConCaptacion
                  key={u.rc20} u={u} rc14={d.rc14}
                  ya={yaEnCartera(u.rc20, d.rc14)}
                  captando={captando === u.rc20}
                  alCaptar={async () => { setCaptando(u.rc20); await alCaptar(u); setCaptando(null); }}
                />
              ))}
            </div>
          )}
        </div>
      )}

      {/* ---------- captar la parcela ENTERA ----------
          Hasta el 28-ago-2026 solo se podia captar bajando a una unidad
          concreta. Delante de un edificio para reformar, de un solar o de una
          rustica, el mapa enseñaba los datos y no dejaba crear nada: habia que
          elegir un piso cualquiera, que es mentira. Se ofrece cuando NO hay
          exactamente una unidad; con una sola, el bloque de abajo ya es la
          parcela entera. */}
      {unidades.length !== 1 && (() => {
        const yaP = yaEnCartera(null, d.rc14);
        const conUnidad = (candidatos || [])
          .filter((p) => p.refCatastralParcela === d.rc14 && p.refCatastral);
        return (
          <div className="rv-cat">
            <div className="rv-sub-ojo">Toda la parcela</div>
            <p className="rv-nota" style={{marginTop: 0}}>
              {unidades.length === 0
                ? "Sin unidades construidas: se crea como parcela, con la superficie de suelo."
                : "Un solo inmueble para el edificio entero y sus " + unidades.length +
                  " unidades. Para captar una sola, baja por los niveles de arriba."}
            </p>

            {yaP.parcela ? (
              <p className="rv-nota">
                Ya está en la cartera como <strong>{yaP.parcela.ref}</strong>. Un inmueble
                existe una sola vez, así que no se crea otro.
              </p>
            ) : (
              <button
                className="map-near"
                style={{marginTop: 6, padding: "6px 12px", fontSize: 11}}
                disabled={captando === "__parcela__"}
                onClick={async () => { setCaptando("__parcela__"); await alCaptar(null); setCaptando(null); }}
              >
                {captando === "__parcela__" ? "Creando…" : "Crear captación de la parcela"}
              </button>
            )}

            {!yaP.parcela && conUnidad.length > 0 && (
              <p className="rv-nota" style={{marginTop: 4}}>
                Ojo: de esta parcela ya tienes {conUnidad.length === 1 ? "un inmueble" : conUnidad.length + " inmuebles"} con
                unidad concreta ({conUnidad.map((p) => p.ref).join(", ")}). Captar el edificio
                entero además de eso suele ser un duplicado.
              </p>
            )}
          </div>
        );
      })()}

      {/* Una sola unidad: no hay nada que navegar. */}
      {unidades.length === 1 && (
        <div className="rv-cat">
          <UnidadConCaptacion
            u={unidades[0]} rc14={d.rc14} unica
            ya={yaEnCartera(unidades[0].rc20, d.rc14)}
            captando={captando === unidades[0].rc20}
            alCaptar={async () => { setCaptando(unidades[0].rc20); await alCaptar(unidades[0]); setCaptando(null); }}
          />
        </div>
      )}

      <div className="rv-cat">
        {d.finca && d.finca.url && (
          <a href={d.finca.url} target="_blank" rel="noreferrer" style={{fontSize: 11, color: "var(--revin-red)"}}>
            Ver el plano en la Sede del Catastro →
          </a>
        )}
        <p className="rv-nota">
          Datos no protegidos. La titularidad y el valor catastral no salen de aquí:
          exigen certificado digital e interés legítimo acreditado. Y el Catastro es
          fiscal, no registral: no prueba la propiedad.
        </p>
      </div>

      <div className="rv-cat">
        <h3 style={{margin: "0 0 8px", fontSize: 11, textTransform: "uppercase", letterSpacing: ".06em"}}>
          Asignar a un inmueble
        </h3>
        <p className="rv-nota" style={{marginTop: 0}}>
          Ata esta parcela a una ficha nuestra. Quedará como <strong>ubicación
          manual</strong>, no como portal exacto.
        </p>

        <select
          className="rv-filtro" style={{width: "100%", marginTop: 8}}
          value={elegido} onChange={(e) => setElegido(e.target.value)}
          aria-label="Inmueble al que asignar la parcela"
        >
          <option value="">Elige un inmueble…</option>
          {orden.map((p) => (
            <option key={p.id} value={p._uid || p.id}>
              {p.lat == null ? "◦ sin ubicar · " : esExacta(p.geoConfianza) ? "" : "~ aproximado · "}
              {p.ref} — {p.addr}
            </option>
          ))}
        </select>

        <button
          className="map-near"
          style={{marginTop: 8, width: "100%", justifyContent: "center", opacity: elegido && !guardando ? 1 : .5}}
          disabled={!elegido || guardando}
          onClick={async () => { setGuardando(true); await alAsignar(elegido); setGuardando(false); }}
        >
          {guardando ? "Guardando…" : "Asignar esta parcela"}
        </button>

        {parcela.aviso && (
          <p ref={refAviso} role="status" aria-live="polite"
             className={"rv-resultado" + (parcela.avisoOk ? " ok" : "")}>
            {parcela.aviso}
          </p>
        )}
        <button className="map-near" style={{marginTop: 8, background: "var(--fg-3)"}} onClick={alCerrar}>Cerrar</button>
      </div>
    </div>
  );
};

// Ficha catastral de un inmueble. Sólo datos NO protegidos: ni titularidad ni
// valor catastral, que exigen certificado e interés legítimo acreditado.
const FichaMapa = ({ p, fmtEUR }) => {
  const cat = p.catastroDatos;
  const unidadConfirmada = p.refCatastral && cat && cat.unidad;

  return (
    <div style={{overflow: "auto", flex: 1}}>
      <div className="rv-cat" style={{borderTop: "none"}}>
        <div className="fila"><dt>Tipo</dt><dd><Pill tone="neutral">{p.type}</Pill></dd></div>
        <div className="fila"><dt>Municipio</dt><dd>{p.town}</dd></div>
        {p.area > 0 && <div className="fila"><dt>Superficie</dt><dd>{p.area} m²</dd></div>}
        {p.price > 0 && <div className="fila"><dt>Precio</dt><dd>{fmtEUR(p.price)}</dd></div>}
        <div className="fila">
          <dt>Ubicación</dt>
          <dd>
            <Pill tone={esExacta(p.geoConfianza) ? "success" : "warning"}>
              {ETIQUETA_GEO[p.geoConfianza] || "—"}
            </Pill>
          </dd>
        </div>
      </div>

      <div className="rv-cat">
        <h3 style={{margin: "0 0 8px", fontSize: 11, textTransform: "uppercase", letterSpacing: ".06em"}}>Catastro</h3>

        {!cat ? (
          <p className="rv-nota">
            Sin datos catastrales fiables. La geocodificación no dio una parcela que
            se pueda dar por buena.
          </p>
        ) : (
          <div>
            <div className="fila"><dt>Parcela</dt><dd><code>{cat.rc14}</code></dd></div>

            {unidadConfirmada ? (
              <div>
                <div className="fila"><dt>Unidad</dt><dd><code>{p.refCatastral}</code></dd></div>
                <div className="fila"><dt>Uso</dt><dd>{cat.unidad.uso || "—"}</dd></div>
                <div className="fila"><dt>Construida</dt><dd>{cat.unidad.superficie != null ? cat.unidad.superficie + " m²" : "—"}</dd></div>
                <div className="fila"><dt>Año</dt><dd>{cat.unidad.ano || "—"}</dd></div>
              </div>
            ) : (
              <div>
                <p className="rv-nota">
                  {(cat.n_unidades || 0) === 1
                    ? "La parcela tiene un solo inmueble, pero no se ha podido dar por bueno que sea éste."
                    : "El edificio tiene " + (cat.n_unidades || "?") + " inmuebles y no se sabe cuál es éste: hacen falta planta y puerta, que están vacías en la ficha."}
                </p>
                {cat.unidad && (
                  <details className="rv-nota">
                    <summary style={{cursor: "pointer"}}>Unidad candidata, sin confirmar</summary>
                    <div style={{marginTop: 4, paddingLeft: 8, borderLeft: "2px solid var(--border-1)"}}>
                      <div><code>{cat.unidad.rc20}</code></div>
                      <div>
                        {(cat.unidad.uso || "uso desconocido")} ·{" "}
                        {cat.unidad.superficie != null ? cat.unidad.superficie + " m²" : "sin superficie"} ·{" "}
                        {cat.unidad.ano || "año desconocido"}
                      </div>
                      {p.area > 0 && cat.unidad.superficie != null && (
                        <div>No cuadra con los {p.area} m² de la ficha, y por eso no se escribió. Compruébalo en el plano antes de usarla.</div>
                      )}
                    </div>
                  </details>
                )}
              </div>
            )}

            {cat.finca && cat.finca.suelo && (
              <div className="fila"><dt>Suelo</dt><dd>{cat.finca.suelo} m²</dd></div>
            )}
            {cat.finca && cat.finca.url && (
              <a href={cat.finca.url} target="_blank" rel="noreferrer"
                 style={{fontSize: 11, color: "var(--revin-red)"}}>
                Ver el plano en la Sede del Catastro →
              </a>
            )}
          </div>
        )}

        <p className="rv-nota">
          El Catastro es fiscal, no registral: no prueba la propiedad ni la superficie
          legal. Para eso hace falta nota simple del Registro.
        </p>
      </div>
    </div>
  );
};

// =========================================================================
// COMMAND PALETTE (⌘K)
// =========================================================================

const CommandPalette = ({ onClose, onNavigate, onOpenProperty }) => {
  const { Icons, DATA } = window;
  const [q, setQ] = React.useState("");
  const [sel, setSel] = React.useState(0);
  const ref = React.useRef(null);
  React.useEffect(() => { ref.current && ref.current.focus(); }, []);

  const queryLower = q.toLowerCase();
  const navItems = [
    { type: "nav", id: "dashboard", label: "Dashboard / Hoy", icon: Icons.Dash },
    { type: "nav", id: "pipeline", label: "Pipeline", icon: Icons.Pipeline },
    { type: "nav", id: "inmuebles", label: "Inmuebles", icon: Icons.Building },
    { type: "nav", id: "clientes", label: "Clientes / Leads", icon: Icons.Users },
    { type: "nav", id: "agenda", label: "Agenda", icon: Icons.Calendar },
    { type: "nav", id: "tareas", label: "Tareas", icon: Icons.CheckSq },
    { type: "nav", id: "mapa", label: "Mapa", icon: Icons.Map },
    { type: "nav", id: "documentos", label: "Documentos", icon: Icons.Folder },
    { type: "nav", id: "facturas", label: "Mis comisiones", icon: Icons.Receipt },
  ].filter(i => !q || i.label.toLowerCase().includes(queryLower));

  const actions = [
    { type: "action", id: "new-prop", label: "Crear nuevo inmueble", icon: Icons.Plus, sub: "Alta condicional", target: "alta" },
    { type: "action", id: "new-client", label: "Añadir cliente / lead", icon: Icons.User, sub: "Form rápido" },
    { type: "action", id: "gen-pdf", label: "Generar contrato de reserva", icon: Icons.Sparkles, sub: "PDF" },
    { type: "action", id: "import-id", label: "Importar lead de Idealista", icon: Icons.Globe },
  ].filter(i => !q || i.label.toLowerCase().includes(queryLower));

  // Las referencias se comparan SIN guiones: escribir "26014" tiene que sacar
  // el inmueble aunque hoy se llame VEN-26-014.
  const soloAlnum = (t) => String(t || "").toLowerCase().replace(/[^a-z0-9]/g, "");
  const qRef = soloAlnum(q);
  const props = DATA.INMUEBLES.filter(p => !q
    || p.addr.toLowerCase().includes(queryLower)
    || (qRef && soloAlnum(p.id).includes(qRef))
    || (qRef && soloAlnum(p.refAnterior).includes(qRef))
    || p.town.toLowerCase().includes(queryLower)).slice(0, 4);
  const clients = DATA.CLIENTS.filter(c => !q || (c.displayName || c.name).toLowerCase().includes(queryLower)).slice(0, 3);

  return (
    <div className="cmdk-backdrop" onClick={onClose}>
      <div className="cmdk" onClick={e => e.stopPropagation()}>
        <div className="cmdk-input">
          <Icons.Search/>
          <input ref={ref} value={q} onChange={e => setQ(e.target.value)} placeholder="Busca inmueble, cliente, acción… o escribe '>' para comandos"/>
          <span className="kbd">ESC</span>
        </div>
        <div className="cmdk-body">
          {navItems.length > 0 && (
            <>
              <div className="cmdk-group">Navegación</div>
              {navItems.map((i, idx) => (
                <div className="cmdk-item" key={i.id} onClick={() => { onNavigate(i.id); onClose(); }}>
                  <i.icon className="ico"/><span>{i.label}</span><span className="sub">↵</span>
                </div>
              ))}
            </>
          )}
          {props.length > 0 && (
            <>
              <div className="cmdk-group">Inmuebles · {props.length}</div>
              {props.map(p => (
                <div className="cmdk-item" key={p.id} onClick={() => { onOpenProperty(p.id); onClose(); }}>
                  <Icons.Building/>
                  <div>
                    <div>{p.addr}</div>
                    <div style={{fontSize: 10, color: "var(--fg-3)"}}>{p.town} · {p.id}</div>
                  </div>
                  <span className="sub">{p.type}</span>
                </div>
              ))}
            </>
          )}
          {clients.length > 0 && (
            <>
              <div className="cmdk-group">Clientes · {clients.length}</div>
              {clients.map(c => (
                <div className="cmdk-item" key={c.id} onClick={() => { onNavigate("clientes"); onClose(); }}>
                  <Icons.User/>
                  <div>
                    <div>{c.displayName || c.name}</div>
                    <div style={{fontSize: 10, color: "var(--fg-3)"}}>{c.type} · {c.origin}</div>
                  </div>
                  <span className="sub">{c.phone.slice(-8)}</span>
                </div>
              ))}
            </>
          )}
          {actions.length > 0 && (
            <>
              <div className="cmdk-group">Acciones rápidas</div>
              {actions.map(a => (
                <div className="cmdk-item" key={a.id} onClick={() => { if (a.target) onNavigate(a.target); onClose(); }}>
                  <a.icon className="ico"/><span>{a.label}</span><span className="sub">{a.sub}</span>
                </div>
              ))}
            </>
          )}
        </div>
        <div className="cmdk-foot">
          <span><span className="kbd">↑↓</span>navegar</span>
          <span><span className="kbd">↵</span>seleccionar</span>
          <span><span className="kbd">ESC</span>cerrar</span>
          <span style={{marginLeft: "auto"}}>REVIN CRM ⌘K</span>
        </div>
      </div>
    </div>
  );
};

// =========================================================================
// NOTIFICATIONS DRAWER
// =========================================================================

const NotificationsDrawer = ({ onClose }) => {
  const { Icons, DATA } = window;
  const [tab, setTab] = React.useState("all");
  const items = DATA.NOTIFICATIONS;
  const filtered = tab === "unread" ? items.filter(n => n.unread) : items;

  return (
    <>
      <div className="notif-back" onClick={onClose}/>
      <div className="notif">
        <div className="notif-h">
          <h3>Notificaciones</h3>
          <button className="close" onClick={onClose}><Icons.X/></button>
        </div>
        <div className="notif-tabs">
          <button className={"notif-tab" + (tab === "all" ? " active" : "")} onClick={() => setTab("all")}>Todas · {items.length}</button>
          <button className={"notif-tab" + (tab === "unread" ? " active" : "")} onClick={() => setTab("unread")}>Sin leer · {items.filter(n => n.unread).length}</button>
        </div>
        <div className="notif-body">
          {filtered.map(n => {
            const IcoMap = { Whatsapp: Icons.Whatsapp, Eye: Icons.Eye, Alert: Icons.Alert, Doc: Icons.Doc, Check: Icons.Check, Plus: Icons.Plus };
            const I = IcoMap[n.icon] || Icons.Bell;
            return (
              <div className={"notif-item" + (n.unread ? " unread" : "")} key={n.id}>
                <div className={"notif-ico " + n.tone}><I/></div>
                <div className="notif-body-text">
                  <div className="notif-title" dangerouslySetInnerHTML={{__html: n.text}}/>
                  <div className="notif-time">{n.time}</div>
                </div>
                {n.unread && <div className="notif-dot"/>}
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
};

window.MapView = MapView;
window.CommandPalette = CommandPalette;
window.NotificationsDrawer = NotificationsDrawer;
