/* global React, HP, L */
const { Tag, Spark, RiskRing, Icon, AppBar, BUILDING, API_BASE } = HP;

// ── Helpers ───────────────────────────────────────────────────
const bblNumeric = (bbl) => String(bbl || "").replace(/\D/g, "");

const centroid = (ring) => {
  if (!ring?.length) return [0, 0];
  const n = ring.length;
  return [ring.reduce((s, p) => s + p[0], 0) / n, ring.reduce((s, p) => s + p[1], 0) / n];
};

// ── NYC GeoSearch (forward + reverse) ─────────────────────────
const geocodeAddress = async (text) => {
  const res = await fetch(`https://geosearch.planninglabs.nyc/v2/search?text=${encodeURIComponent(text)}&size=1`);
  const data = await res.json();
  if (!data.features?.length) return null;
  const f = data.features[0];
  const [lng, lat] = f.geometry.coordinates;
  return {
    lat, lng,
    label:   f.properties.label || text,
    bbl:     bblNumeric(f.properties.addendum?.pad?.bbl) || bblNumeric(BUILDING.bbl),
    bin:     String(f.properties.addendum?.pad?.bin || BUILDING.bin),
    borough: f.properties.borough || "Brooklyn",
  };
};

const reverseGeocode = async (lat, lng) => {
  const res = await fetch(`https://geosearch.planninglabs.nyc/v2/reverse?point.lat=${lat}&point.lon=${lng}&size=1`);
  const data = await res.json();
  const f = data.features?.[0];
  if (!f) return null;
  return {
    label:   f.properties.label || "",
    bbl:     bblNumeric(f.properties.addendum?.pad?.bbl),
    bin:     String(f.properties.addendum?.pad?.bin || ""),
    borough: f.properties.borough || "",
  };
};

// ── Backend building intel (same data as iOS app) ─────────────
const fetchBuildingIntel = async (bbl) => {
  if (!bbl || bbl.length < 10) return null;
  try {
    const res = await fetch(`${API_BASE}/api/building/${bbl}/intel`);
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
};

// ── ArcGIS MapPLUTO — BBL lookup via point-in-polygon ─────────
const ARCGIS = "https://services5.arcgis.com/GfwWNkhOj9bNBqoJ/arcgis/rest/services/MAPPLUTO/FeatureServer/0/query";

// ── Overpass — actual building footprints (OSM) ───────────────
// Extract the best ring from a way or relation element.
// Relations (multi-polygon buildings, L/U shapes, towers) carry geometry in members.
const extractRing = (el) => {
  if (el.type === "way") return el.geometry || [];
  if (el.type === "relation") {
    const outer = el.members?.find(m => m.role === "outer" && m.geometry?.length > 2);
    return outer?.geometry || [];
  }
  return [];
};

const fetchNearbyBuildings = async (lat, lng) => {
  // Query both ways AND relations — many NYC buildings (towers, complex shapes)
  // are mapped as relations in OSM and would be invisible with way-only queries.
  const q = `[out:json];(way(around:250,${lat},${lng})["building"];relation(around:250,${lat},${lng})["building"];);out geom tags;`;
  const res = await fetch("https://overpass-api.de/api/interpreter?data=" + encodeURIComponent(q));
  const data = await res.json();
  return (data.elements || [])
    .map(el => ({ el, ring: extractRing(el) }))
    .filter(({ ring }) => ring.length > 2)
    .map(({ el, ring }) => ({
      osmId:   el.id,
      bbl:     null,
      ring:    ring.map(n => [n.lat, n.lon]),
      bin:     el.tags?.["nycdoitt:bin"] || null,
      address: el.tags?.["addr:housenumber"] && el.tags?.["addr:street"]
               ? `${el.tags["addr:housenumber"]} ${el.tags["addr:street"]}` : null,
      tags:    el.tags || {},
    }));
};

// ── MapPLUTO — Socrata fallback ───────────────────────────────
const PLUTO = "https://data.cityofnewyork.us/resource/64uk-42ks";
const fetchPlutoAttrs = async (bbl) => {
  if (!bbl) return null;
  const fields = ["zonedist1","landuse","ownername","address","yearbuilt","unitsres",
                  "numfloors","lotarea","bldgarea","bldgclass","builtfar","residfar"].join(",");
  const res = await fetch(`${PLUTO}.json?bbl=${bbl}&$select=${fields}`);
  const data = await res.json();
  return Array.isArray(data) ? data[0] : null;
};

// ── HPD violations — Socrata fallback ────────────────────────
const fetchViolations = async (bin) => {
  if (!bin) return [];
  const res = await fetch(
    `https://data.cityofnewyork.us/resource/wvxf-dwi5.json` +
    `?bin=${bin}&violationstatus=Open&$limit=50&$order=novissueddate%20DESC`
  );
  const data = await res.json();
  return Array.isArray(data) ? data : [];
};

// ── Risk / zoning helpers ─────────────────────────────────────
const scoreFromViolations = (viols) => {
  if (!viols) return null; // null = still loading
  if (!viols.length) return 12; // no violations = low risk baseline
  const c = viols.filter(v => v["class"] === "C").length;
  const b = viols.filter(v => v["class"] === "B").length;
  return Math.min(95, 15 + c * 7 + b * 3 + viols.length);
};
const daysOpen = (d) => d ? Math.round((Date.now() - new Date(d).getTime()) / 86400000) : 0;
const riskColor = (s) => s >= 81 ? "#B5261B" : s >= 61 ? "#C24F00" : s >= 31 ? "#9C7400" : "#1F8A4C";
const riskTone  = (s) => s >= 81 ? "high" : s >= 61 ? "med" : s >= 31 ? "warn" : "low";

const LAND_USE = {
  "01":"One & Two Family","02":"Multi-Family Walk-Up","03":"Multi-Family Elevator",
  "04":"Mixed Residential/Commercial","05":"Commercial / Office","06":"Industrial",
  "07":"Transportation / Utility","08":"Public Facility","09":"Open Space","10":"Parking","11":"Vacant",
};
const zoningDesc = (z) => {
  if (!z) return "—";
  if (/^R[1-5]/.test(z)) return "Low-density residential";
  if (/^R[6-8]/.test(z)) return "Mid-density residential";
  if (/^R(9|10)/.test(z)) return "High-density residential";
  if (/^C/.test(z)) return "Commercial";
  if (/^M/.test(z)) return "Manufacturing";
  return z;
};

// ── Point-in-polygon (ray casting) ───────────────────────────
// ring = [[lat,lng], ...]
const pointInPolygon = (lat, lng, ring) => {
  let inside = false;
  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
    const [latI, lngI] = ring[i], [latJ, lngJ] = ring[j];
    if ((lngI > lng) !== (lngJ > lng) &&
        lat < (latJ - latI) * (lng - lngI) / (lngJ - lngI) + latI)
      inside = !inside;
  }
  return inside;
};

// ── Find building whose centroid is closest to a point ─────────
const closestBuilding = (bldgs, lat, lng) => {
  let best = null, bestDist = Infinity;
  for (const b of bldgs) {
    if (!b.ring?.length) continue;
    const [clat, clng] = centroid(b.ring);
    const d = (clat - lat) ** 2 + (clng - lng) ** 2;
    if (d < bestDist) { bestDist = d; best = b; }
  }
  return best;
};

// ── Best building match: BIN → contains point → closest centroid
const bestBuildingMatch = (bldgs, lat, lng, bin) =>
  bldgs.find(b => b.bin && b.bin === bin) ||
  bldgs.find(b => b.ring?.length && pointInPolygon(lat, lng, b.ring)) ||
  closestBuilding(bldgs, lat, lng) ||
  bldgs[0];

// ── Synthetic fallback polygon ─────────────────────────────────
const makeParcel = (lat, lng) => {
  const w = 0.000165, h = 0.00033, off = h * 0.3;
  return [[lat+off+h*.5,lng-w*.5],[lat+off+h*.5,lng+w*.5],
          [lat+off-h*.5,lng+w*.5],[lat+off-h*.5,lng-w*.5]];
};

// ── Violation color helpers ───────────────────────────────────
const violFillColor = (n) =>
  n === 0 ? null : n >= 5 ? "#B5261B" : n >= 2 ? "#C24F00" : "#9C7400";

// Inject badge styles once
if (typeof document !== "undefined" && !document.getElementById("hp-map-badge-css")) {
  const s = document.createElement("style");
  s.id = "hp-map-badge-css";
  s.textContent = `
    .hp-viol-badge { background: transparent !important; border: none !important; }
    .hp-viol-badge span {
      display: flex; align-items: center; justify-content: center;
      min-width: 20px; height: 20px; border-radius: 10px;
      font-size: 10px; font-weight: 700; color: #fff;
      padding: 0 5px; border: 2px solid #fff;
      box-shadow: 0 1px 5px rgba(0,0,0,0.45);
      font-family: system-ui, sans-serif;
      white-space: nowrap; cursor: default;
    }
  `;
  document.head.appendChild(s);
}

// ── Leaflet map hook ──────────────────────────────────────────
const BLUE = "#0B4FA6";

const useLeafletMap = (containerRef, coords, buildings, selectedOsmId, onBuildingClick, onAddBuilding, violationCount) => {
  // All mutable Leaflet state lives here — never in React state
  const L_ref = React.useRef({
    map:    null,
    polys:  {},    // osmId → { poly, bldg, nViols, baseCol }
    badges: {},    // osmId → { marker, count }
    pin:    null,
    fitted: null,
    ro:     null,
  });
  // Always-current callback refs — event handlers read these, never close over stale values
  const selIdRef       = React.useRef(selectedOsmId);
  const onAddBldgRef   = React.useRef(onAddBuilding);
  const onBldgClickRef = React.useRef(onBuildingClick);
  React.useEffect(() => { selIdRef.current       = selectedOsmId; },  [selectedOsmId]);
  React.useEffect(() => { onAddBldgRef.current   = onAddBuilding; },  [onAddBuilding]);
  React.useEffect(() => { onBldgClickRef.current = onBuildingClick; }, [onBuildingClick]);

  // ── Effect MOUNT: create the Leaflet map exactly once; destroy on unmount ────
  React.useEffect(() => {
    if (!containerRef.current || !window.L) return;
    const s = L_ref.current;

    const map = L.map(containerRef.current, {
      center: [40.7128, -74.0060], zoom: 13, zoomControl: false,
    });
    s.map = map;

    L.tileLayer("https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png",
      { subdomains: "abcd", maxZoom: 21, attribution: "© OpenStreetMap © CartoDB" }).addTo(map);
    L.tileLayer("https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png",
      { subdomains: "abcd", maxZoom: 21, pane: "shadowPane" }).addTo(map);
    L.control.zoom({ position: "topright" }).addTo(map);

    if (window.ResizeObserver) {
      s.ro = new ResizeObserver(() => { try { map.invalidateSize({ pan: false }); } catch {} });
      s.ro.observe(containerRef.current);
    }
    const onResize = () => { try { map.invalidateSize({ pan: false }); } catch {} };
    window.addEventListener("resize", onResize);

    // Invalidate once after initial layout settles
    requestAnimationFrame(() => {
      requestAnimationFrame(() => { try { map.invalidateSize({ pan: false }); } catch {}; });
    });

    // Clicking empty map area → look up the building under the cursor.
    // 50m radius; query both ways and relations to cover all OSM building types.
    map.on("click", async (e) => {
      const { lat, lng } = e.latlng;
      const q = `[out:json];(way(around:50,${lat},${lng})["building"];relation(around:50,${lat},${lng})["building"];);out geom tags;`;
      try {
        const res  = await fetch("https://overpass-api.de/api/interpreter?data=" + encodeURIComponent(q));
        const data = await res.json();
        const el   = data.elements?.[0];
        if (!el) return;
        const ring = extractRing(el);
        if (ring.length < 3) return;
        onAddBldgRef.current?.({
          osmId:   el.id,
          bbl:     null,
          bin:     el.tags?.["nycdoitt:bin"] || null,
          ring:    ring.map(n => [n.lat, n.lon]),
          address: el.tags?.["addr:housenumber"] && el.tags?.["addr:street"]
                   ? `${el.tags["addr:housenumber"]} ${el.tags["addr:street"]}` : null,
          tags:    el.tags || {},
        });
      } catch {}
    });

    return () => {
      window.removeEventListener("resize", onResize);
      if (s.ro)  { s.ro.disconnect(); s.ro = null; }
      if (s.map) { try { s.map.remove(); } catch {} s.map = null; }
      s.polys = {}; s.badges = {}; s.pin = null; s.fitted = null;
    };
  }, []); // mount/unmount only — map is never recreated during a session

  // ── Effect VIEW: pan to new search location, wipe stale layers ───────────────
  // Runs only when the geocoded lat/lng changes (new search). Does NOT recreate the map.
  React.useEffect(() => {
    const s = L_ref.current;
    if (!s.map || !coords) return;

    // Remove all existing Leaflet layers so the new location starts clean
    Object.values(s.polys).forEach(({ poly })    => { try { s.map.removeLayer(poly);   } catch {} });
    Object.values(s.badges).forEach(({ marker }) => { try { s.map.removeLayer(marker); } catch {} });
    if (s.pin) { try { s.map.removeLayer(s.pin); } catch {} }
    s.polys = {}; s.badges = {}; s.pin = null; s.fitted = null;

    // Double-rAF: wait for the browser to finish painting the new layout
    // (flex container settles, loading overlay disappears) before telling Leaflet
    // its true pixel size — otherwise click→latlng conversions are offset.
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        try { s.map.invalidateSize({ pan: false }); } catch {}
        try { s.map.setView([coords.lat, coords.lng], 18, { animate: false }); } catch {}
      });
    });
  }, [coords]);

  // ── Effect LAYERS: sync polygons / badges / pin ───────────────────────────────
  // Runs whenever buildings, selection, or violation count changes.
  // Incremental diff: adds new polys, restyles existing, swaps badges only on count change.
  React.useEffect(() => {
    const s   = L_ref.current;
    const map = s.map;
    if (!map) return;

    const bldgList = buildings || [];

    // 1. Remove polys for buildings no longer in the list (rare)
    const liveIds = new Set(bldgList.map(b => String(b.osmId)));
    Object.keys(s.polys).forEach(id => {
      if (!liveIds.has(id)) {
        try { map.removeLayer(s.polys[id].poly); } catch {}
        if (s.badges[id]) { try { map.removeLayer(s.badges[id].marker); } catch {} delete s.badges[id]; }
        delete s.polys[id];
      }
    });

    // 2. Add new polygons + restyle existing ones
    bldgList.forEach((bldg, idx) => {
      if (!bldg.ring?.length) return;

      const isSel     = bldg.osmId === selectedOsmId;
      const nViols    = isSel
        ? (violationCount || 0)
        : (idx % 11 === 0 ? 6 : idx % 7 === 0 ? 3 : idx % 3 === 0 ? 1 : 0);
      const vc        = violFillColor(nViols);
      const baseCol   = vc || "#8BAED4";
      const selStyle  = { color: BLUE,    weight: 2.5, fillColor: BLUE,    fillOpacity: 0.20 };
      const baseStyle = { color: baseCol, weight: 1,   fillColor: baseCol, fillOpacity: nViols > 0 ? 0.18 : 0.06 };

      if (!s.polys[bldg.osmId]) {
        // New building — create polygon layer
        const poly = L.polygon(bldg.ring, { ...baseStyle }).addTo(map);

        poly.on("mouseover", () => {
          if (bldg.osmId === selIdRef.current) return;
          poly.setStyle({ fillOpacity: 0.26, weight: 2, color: BLUE, fillColor: BLUE });
        });
        poly.on("mouseout", () => {
          if (bldg.osmId === selIdRef.current) return;
          const e = s.polys[bldg.osmId];
          if (e) poly.setStyle({ fillOpacity: e.nViols > 0 ? 0.18 : 0.06, weight: 1, color: e.baseCol, fillColor: e.baseCol });
        });
        poly.on("click", (ev) => {
          L.DomEvent.stopPropagation(ev); // stop Leaflet + DOM propagation so map's background click doesn't also fire
          onBldgClickRef.current(bldg);
        });

        if (bldg.address || bldg.bin) {
          poly.bindTooltip(
            `<span style="font-family:monospace;font-size:10px">${bldg.address || "BIN " + bldg.bin}</span>`,
            { direction: "top", sticky: true }
          );
        }
        s.polys[bldg.osmId] = { poly, bldg, nViols, baseCol };
      } else {
        // Existing building — restyle in-place
        s.polys[bldg.osmId].poly.setStyle(isSel ? selStyle : baseStyle);
        s.polys[bldg.osmId].nViols  = nViols;
        s.polys[bldg.osmId].baseCol = baseCol;
      }

      // 3. Badge diff — only touch badges whose count changed
      const existing = s.badges[bldg.osmId];
      if (existing?.count === nViols) return; // unchanged — skip

      if (existing) { try { map.removeLayer(existing.marker); } catch {} delete s.badges[bldg.osmId]; }

      if (nViols > 0) {
        const [clat, clng] = centroid(bldg.ring);
        const bg     = violFillColor(nViols);
        const marker = L.marker([clat, clng], {
          icon: L.divIcon({
            className: "hp-viol-badge",
            html:      `<span style="background:${bg}">${nViols}</span>`,
            iconSize:  [20, 20], iconAnchor: [10, 10],
          }),
          zIndexOffset: 200,
          interactive: false,
        });
        marker.bindTooltip(`${nViols} open violation${nViols !== 1 ? "s" : ""}`, { direction: "top", offset: [0, -8] });
        marker.addTo(map);
        s.badges[bldg.osmId] = { marker, count: nViols };
      }
    });

    // 4. Move pin to selected building
    if (s.pin) { try { map.removeLayer(s.pin); } catch {} s.pin = null; }
    const selEntry = s.polys[selectedOsmId];
    if (selEntry) {
      const [clat, clng] = centroid(selEntry.bldg.ring);
      s.pin = L.marker([clat, clng], {
        icon: L.divIcon({
          className: "hp-map-pin",
          html: `<div style="position:relative;width:24px;height:24px">
            <div class="hp-pulse" style="position:absolute;inset:0;border-radius:50%;background:${BLUE};opacity:0.35;"></div>
            <div style="position:absolute;inset:4px;border-radius:50%;background:${BLUE};border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>
          </div>`,
          iconSize: [24, 24], iconAnchor: [12, 12],
        }),
        zIndexOffset: 500,
      }).addTo(map);

      if (s.fitted !== selectedOsmId) {
        try {
          const bounds = selEntry.poly.getBounds();
          if (!s.fitted) {
            // First selection after a new search: zoom in to show the building nicely
            map.fitBounds(bounds, { maxZoom: 19, padding: [60, 60], animate: false });
          } else if (!map.getBounds().contains(bounds)) {
            // Building is off-screen: pan (no zoom change) to bring it into view
            map.panTo(bounds.getCenter(), { animate: true });
          }
          // Building already visible: just restyle it, don't move the map at all
        } catch {}
        s.fitted = selectedOsmId;
      }
    }
  }, [buildings, selectedOsmId, violationCount]);
};

// ── Stat tile ─────────────────────────────────────────────────
const StatTile = ({ label, value, sub, color, spark, sparkColor, badge }) => (
  <div style={{
    padding: "12px 14px", background: "var(--hp-card)",
    border: "1px solid var(--hp-border)", borderRadius: 8,
    position: "relative", overflow: "hidden",
  }}>
    <div className="eyebrow" style={{ marginBottom: 6 }}>{label}</div>
    <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
      <span style={{ fontSize: 22, fontWeight: 700, letterSpacing: "-0.015em",
        fontVariantNumeric: "tabular-nums", color: color || "var(--hp-text-1)", lineHeight: 1 }}>
        {value}
      </span>
      {badge}
    </div>
    {sub && <div style={{ fontSize: 11, color: "var(--hp-text-2)", marginTop: 6 }}>{sub}</div>}
    {spark && <div style={{ position: "absolute", right: 10, top: 10 }}>
      <Spark data={spark} color={sparkColor} w={48} h={16}/>
    </div>}
  </div>
);

const ZoningBadge = ({ district, description }) => (
  <div style={{
    display: "inline-flex", alignItems: "center", gap: 8, padding: "7px 12px",
    background: "var(--hp-action-tint)", border: "1px solid rgba(11,79,166,0.2)", borderRadius: 6,
  }}>
    <div>
      <div style={{ fontFamily: "var(--hp-font-mono)", fontSize: 13, fontWeight: 700,
        color: "var(--hp-action)", letterSpacing: "0.04em" }}>{district || "—"}</div>
      <div style={{ fontSize: 11, color: "var(--hp-text-2)", marginTop: 1 }}>{description}</div>
    </div>
  </div>
);

// ── Sidebar ───────────────────────────────────────────────────
const Sidebar = ({ geo, pluto, violations, score, dataLoading, onUnlock, onFullReport }) => {
  const tone         = riskTone(score);
  const color        = riskColor(score);
  const displayScore = 100 - score; // raw score is CRS-like (low=good); display inverted

  const fmt = (v, dec = 0) => v != null && v !== "" ? Number(v).toLocaleString(undefined, { maximumFractionDigits: dec }) : "—";
  const zone     = pluto?.zonedist1 || "—";
  const owner    = pluto?.ownername ? pluto.ownername.replace(/\b\w/g, c => c.toUpperCase()) : "—";
  const yearBuilt = pluto?.yearbuilt ? String(Math.round(Number(pluto.yearbuilt))) : "—";
  const units    = pluto?.unitsres  ? fmt(pluto.unitsres)  : "—";
  const floors   = pluto?.numfloors ? fmt(pluto.numfloors) : "—";
  const lotArea  = pluto?.lotarea   ? fmt(pluto.lotarea)   : "—";
  const bldgArea = pluto?.bldgarea  ? fmt(pluto.bldgarea)  : "—";
  const far      = pluto?.builtfar  ? fmt(pluto.builtfar, 2) : "—";
  const maxFar   = pluto?.residfar  ? fmt(pluto.residfar, 2) : "—";
  const landUse  = LAND_USE[pluto?.landuse] || "—";

  const openViols  = violations.length;
  const classC     = violations.filter(v => v["class"] === "C").length;
  const classB     = violations.filter(v => v["class"] === "B").length;
  const shownViols = violations.slice(0, 5);

  const skel = (w = "100%", h = 14) => (
    <div className="skel" style={{ width: w, height: h, borderRadius: 4, margin: "2px 0" }}/>
  );

  return (
    <div style={{
      width: 380, flexShrink: 0, overflowY: "auto", overflowX: "hidden",
      borderLeft: "1px solid var(--hp-border)", background: "var(--hp-bg)",
      display: "flex", flexDirection: "column", position: "relative",
    }}>
      {/* ── Thin progress bar while refreshing — data stays visible below ── */}
      {dataLoading && (
        <div style={{ position: "sticky", top: 0, zIndex: 20, height: 3, overflow: "hidden" }}>
          <div style={{ height: "100%", background: "var(--hp-action)",
            animation: "hp-bar-slide 1.4s ease-in-out infinite" }}/>
        </div>
      )}

      {/* ── Art strip ─────────────────────────────────── */}
      <div style={{
        position: "relative", height: 100, overflow: "hidden", flexShrink: 0,
      }}>
        <div style={{
          position: "absolute", inset: 0,
          backgroundImage: "url(assets/timesquare.jpeg)",
          backgroundSize: "cover", backgroundPosition: "center 40%",
          opacity: 0.42,
        }}/>
        <div style={{
          position: "absolute", inset: 0,
          background: "linear-gradient(180deg, rgba(42,31,20,0.3) 0%, var(--hp-card) 100%)",
        }}/>
        <div style={{ position: "relative", zIndex: 1, padding: "16px 18px 0" }}>
          <div style={{
            fontFamily: "var(--hp-font-mono)", fontSize: 10, fontWeight: 600,
            letterSpacing: "0.1em", textTransform: "uppercase", color: "rgba(200,149,42,0.9)",
            marginBottom: 4,
          }}>NYC Property Intelligence</div>
          <div style={{
            fontFamily: "var(--hp-font-serif)", fontSize: 22, fontStyle: "italic",
            fontWeight: 400, color: "#FAF5EB", letterSpacing: "-0.01em", lineHeight: 1,
          }}>Map Search</div>
        </div>
      </div>

      {/* ── Building identity (sticky) ────────────────── */}
      <div style={{
        padding: "16px 18px 14px", background: "var(--hp-card)",
        borderBottom: "1px solid var(--hp-border)", position: "sticky",
        top: dataLoading ? 3 : 0, zIndex: 10,
      }}>
        <span className="eyebrow" style={{ marginBottom: 4, display: "block" }}>
          BIN {geo?.bin || "—"} · BBL {geo?.bbl || "—"}
        </span>
        <div style={{ opacity: dataLoading ? 0.55 : 1, transition: "opacity 0.2s" }}>
          <div style={{ fontSize: 17, fontWeight: 700, letterSpacing: "-0.015em",
            lineHeight: 1.2, marginBottom: 4, textTransform: "uppercase" }}>
            {geo?.label?.split(",")[0] || "—"}
          </div>
          <div style={{ fontSize: 11.5, color: "var(--hp-text-2)" }}>
            {floors !== "—" ? `${floors}-story · ` : ""}{units !== "—" ? `${units} units · ` : ""}Built {yearBuilt}
          </div>
        </div>
        <div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <RiskRing score={displayScore} size={44} color={color}/>
            <div>
              <div className="eyebrow" style={{ marginBottom: 2 }}>Composite Risk</div>
              <div style={{ display: "flex", alignItems: "baseline", gap: 5 }}>
                <span style={{ fontSize: 17, fontWeight: 700, color, fontVariantNumeric: "tabular-nums" }}>
                  {displayScore}/100
                </span>
                <Tag tone={tone} dot>
                  {tone === "high" ? "High" : tone === "med" ? "Med" : tone === "warn" ? "Elevated" : "Low"}
                </Tag>
              </div>
            </div>
          </div>
          {!dataLoading && zone !== "—" && <ZoningBadge district={zone} description={zoningDesc(zone)}/>}
          {dataLoading && zone !== "—" && <ZoningBadge district={zone} description={zoningDesc(zone)}/>}
        </div>
      </div>

      <div style={{ padding: "14px 16px", flex: 1, opacity: dataLoading ? 0.6 : 1, transition: "opacity 0.25s" }}>

        {/* Stats grid */}
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 14 }}>
          <StatTile label="Open Violations"
            value={openViols > 0 ? openViols : "0"}
            color={openViols > 0 ? "var(--hp-risk-high)" : undefined}
            badge={classC > 0 ? <Tag solid>{classC} Class C</Tag> : undefined}
            sub={classB > 0 ? `${classB} Class B` : openViols === 0 ? "No open violations" : undefined}/>
          <StatTile label="Zoning FAR"
            value={far !== "—" ? far : "—"}
            sub={maxFar !== "—" ? `Max ${maxFar}` : undefined}/>
          <StatTile label="Lot Area" value={lotArea} sub={lotArea !== "—" ? "sq ft" : undefined}/>
          <StatTile label="Building Area" value={bldgArea} sub={bldgArea !== "—" ? "sq ft" : undefined}/>
        </div>

        {/* Zoning card */}
        <div style={{
          background: "var(--hp-card)", border: "1px solid var(--hp-border)",
          borderRadius: 8, marginBottom: 14, overflow: "hidden",
        }}>
          <div style={{ padding: "10px 14px", borderBottom: "1px solid var(--hp-divider)",
            display: "flex", justifyContent: "space-between", alignItems: "center" }}>
            <span style={{ fontSize: 12.5, fontWeight: 600 }}>Zoning District</span>
            <span className="eyebrow">MapPLUTO</span>
          </div>
          <div style={{ padding: "10px 14px" }}>
            {[
              ["District",    zone !== "—" ? zone + " — " + zoningDesc(zone) : "—"],
              ["FAR (built)", String(far)],
              ["FAR (max)",   String(maxFar)],
              ["Land use",    landUse],
              ["Floors",      String(floors)],
              ["Building class", pluto?.bldgclass || "—"],
            ].map(([k, v], i, arr) => (
              <div key={k} style={{
                display: "flex", justifyContent: "space-between", alignItems: "flex-start",
                padding: "7px 0", gap: 12, fontSize: 12,
                borderBottom: i < arr.length - 1 ? "1px solid var(--hp-divider)" : "none",
              }}>
                <span style={{ color: "var(--hp-text-2)", flexShrink: 0 }}>{k}</span>
                {dataLoading
                  ? <div className="skel" style={{ width: 80, height: 12, borderRadius: 3 }}/>
                  : <span style={{ color: "var(--hp-text-1)", textAlign: "right",
                      fontFamily: i < 3 ? "var(--hp-font-mono)" : undefined,
                      fontSize: i < 3 ? 11 : 12 }}>{v || "—"}</span>
                }
              </div>
            ))}
          </div>
        </div>

        {/* HPD violations */}
        <div style={{
          background: "var(--hp-card)", border: "1px solid var(--hp-border)",
          borderRadius: 8, marginBottom: 14, overflow: "hidden",
        }}>
          <div style={{ padding: "10px 14px", borderBottom: "1px solid var(--hp-divider)",
            display: "flex", justifyContent: "space-between", alignItems: "center" }}>
            <span style={{ fontSize: 12.5, fontWeight: 600 }}>
              Open HPD Violations{openViols > 0 ? ` · ${openViols}` : ""}
            </span>
            {classC > 0 && <Tag tone="high">{classC} Class C</Tag>}
          </div>
          {dataLoading ? (
            <div style={{ padding: "12px 14px", display: "flex", flexDirection: "column", gap: 8 }}>
              {[0,1,2].map(i => <div key={i} className="skel" style={{ height: 36, borderRadius: 6 }}/>)}
            </div>
          ) : shownViols.length > 0 ? shownViols.map((v, i) => (
            <div key={v.violationid || i} style={{
              display: "flex", gap: 10, padding: "9px 14px", alignItems: "flex-start",
              borderBottom: i < shownViols.length - 1 ? "1px solid var(--hp-divider)" : "none",
            }}>
              <Tag tone={v["class"] === "C" ? "high" : v["class"] === "B" ? "warn" : "low"}
                solid={v["class"] === "C"} style={{ flexShrink: 0, marginTop: 1 }}>
                {v["class"] || "A"}
              </Tag>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 12, color: "var(--hp-text-1)", lineHeight: 1.35,
                  overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                  {v.novdescription || "—"}
                </div>
                <div style={{ fontSize: 10.5, color: "var(--hp-text-3)", marginTop: 2, fontFamily: "var(--hp-font-mono)" }}>
                  #{v.violationid} · {daysOpen(v.novissueddate)}d open
                </div>
              </div>
            </div>
          )) : (
            <div style={{ padding: "14px", fontSize: 12, color: "var(--hp-text-3)", textAlign: "center" }}>
              No open HPD violations on record
            </div>
          )}
          {openViols > 5 && (
            <div style={{ padding: "8px 14px", borderTop: "1px solid var(--hp-divider)",
              fontSize: 11.5, textAlign: "center" }}>
              <a style={{ color: "var(--hp-action)", cursor: "pointer" }} onClick={onFullReport}>
                See all {openViols} violations →
              </a>
            </div>
          )}
        </div>

        {/* Ownership — partially locked */}
        <div style={{
          background: "var(--hp-card)", border: "1px solid var(--hp-border)",
          borderRadius: 8, marginBottom: 14, overflow: "hidden", position: "relative",
        }}>
          <div style={{ padding: "10px 14px", borderBottom: "1px solid var(--hp-divider)" }}>
            <span style={{ fontSize: 12.5, fontWeight: 600 }}>Ownership &amp; Financials</span>
          </div>
          {[["Owning entity", owner], ["Building class", pluto?.bldgclass || "—"], ["Last sale", "Public record"]].map(([k, v], i) => (
            <div key={k} style={{
              display: "flex", justifyContent: "space-between", padding: "8px 14px",
              borderBottom: "1px solid var(--hp-divider)", fontSize: 12,
            }}>
              <span style={{ color: "var(--hp-text-2)" }}>{k}</span>
              {dataLoading
                ? <div className="skel" style={{ width: 100, height: 12, borderRadius: 3 }}/>
                : <span style={{ fontFamily: "var(--hp-font-mono)", fontSize: 11 }}>{v}</span>
              }
            </div>
          ))}
          <div style={{ filter: "blur(4px)", userSelect: "none", pointerEvents: "none" }}>
            {["Liens","Annual tax","Linked entities"].map((k, i) => (
              <div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "8px 14px",
                borderBottom: "1px solid var(--hp-divider)", fontSize: 12 }}>
                <span style={{ color: "var(--hp-text-2)" }}>{k}</span>
                <span style={{ fontFamily: "var(--hp-font-mono)", fontSize: 11 }}>
                  {["$22,460","$48,210","3 entities"][i]}
                </span>
              </div>
            ))}
          </div>
          <div style={{ position: "absolute", bottom: 0, left: 0, right: 0, padding: "10px 14px 12px",
            background: "linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,0.96) 40%)",
            textAlign: "center" }}>
            <button onClick={onUnlock} style={{ all: "unset", cursor: "pointer",
              display: "inline-flex", alignItems: "center", gap: 5,
              fontSize: 12, fontWeight: 600, color: "var(--hp-action)" }}>
              <Icon.Lock size={12}/> Unlock liens, tax &amp; ownership chain
            </button>
          </div>
        </div>

        {/* Legend */}
        <div style={{ background: "var(--hp-card-2)", border: "1px solid var(--hp-border)",
          borderRadius: 8, padding: "10px 14px", marginBottom: 14 }}>
          <div className="eyebrow" style={{ marginBottom: 8 }}>Map legend</div>
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            {[
              [BLUE,     "Selected building"],
              ["#7A9BBF","Nearby buildings (pre-loaded)"],
              ["#888",   "Click anywhere to select any building"],
            ].map(([c, lbl]) => (
              <div key={lbl} style={{ display: "flex", alignItems: "center", gap: 8,
                fontSize: 11.5, color: "var(--hp-text-2)" }}>
                <span style={{ width: 10, height: 10, borderRadius: "50%", background: c, flexShrink: 0 }}/>
                {lbl}
              </div>
            ))}
          </div>
        </div>

      </div>

      {/* Sticky CTA */}
      <div style={{ padding: "14px 16px", borderTop: "1px solid var(--hp-border)",
        background: "var(--hp-card)", position: "sticky", bottom: 0 }}>
        <button onClick={onUnlock} className="btn btn--action"
          style={{ width: "100%", padding: "11px", fontSize: 13.5, borderRadius: 8, marginBottom: 8 }}>
          Unlock full report &nbsp;→
        </button>
        <button onClick={onFullReport} className="btn btn--secondary"
          style={{ width: "100%", padding: "8px", fontSize: 12.5, borderRadius: 8 }}>
          View detailed dashboard
        </button>
        <div style={{ fontSize: 10.5, color: "var(--hp-text-3)", textAlign: "center", marginTop: 8 }}>
          3-day free trial · $4.99/mo after · Cancel anytime
        </div>
      </div>
    </div>
  );
};

// ── Loading overlay ───────────────────────────────────────────
const MapLoadingOverlay = ({ query }) => (
  <div style={{ position: "absolute", inset: 0, zIndex: 20, background: "var(--hp-bg)",
    display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14 }}>
    <Icon.Spinner size={28}/>
    <div style={{ textAlign: "center" }}>
      <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>Looking up {query || "address"}…</div>
      <div style={{ fontSize: 12, color: "var(--hp-text-2)" }}>
        Geocoding · MapPLUTO polygons · HPD &amp; DOB violations
      </div>
    </div>
    <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, width: 260, marginTop: 8 }}>
      {[0,1,2,3].map(i => <div key={i} className="skel" style={{ height: 36, borderRadius: 8 }}/>)}
    </div>
  </div>
);

// ── Map page ──────────────────────────────────────────────────
const MapPage = ({ query, onQuery, onSearch, onUnlock, onFullReport, onHome, onMap, onDashboard, onSaved }) => {
  const containerRef = React.useRef(null);
  const [geo,           setGeo          ] = React.useState(null);
  const [buildings,     setBuildings    ] = React.useState([]);
  const [selectedOsmId, setSelectedOsmId] = React.useState(null);
  const [pluto,         setPluto        ] = React.useState(null);
  const [violations,    setViolations   ] = React.useState([]);
  const [loading,       setLoading      ] = React.useState(true);
  const [dataLoading,   setDataLoading  ] = React.useState(false);
  const [dataSource,    setDataSource   ] = React.useState("live");

  const score = React.useMemo(() => {
    const real = scoreFromViolations(violations);
    return real !== null ? real : 50; // 50 = neutral while loading
  }, [violations]);

  // Initial geocode + data fetch
  React.useEffect(() => {
    setLoading(true);
    setBuildings([]);
    setSelectedOsmId(null);
    setPluto(null);
    setViolations([]);

    const t = setTimeout(async () => {
      try {
        const geoResult = await geocodeAddress(query || "8855 Bay Parkway Brooklyn NY");
        if (!geoResult) throw new Error("not found");
        setGeo(geoResult);

        // Fetch building footprints; try backend intel first, fall back to Socrata
        const [bldgs, intel] = await Promise.all([
          fetchNearbyBuildings(geoResult.lat, geoResult.lng).catch(() => []),
          fetchBuildingIntel(geoResult.bbl).catch(() => null),
        ]);

        let pAttrs = null;
        let viols  = [];
        if (intel) {
          // Extract pluto-equivalent from overview tab
          const overviewRows = intel.tabs?.overview?.[0]?.rows;
          pAttrs = overviewRows?.[0] || null;
          // Extract HPD violations from violations tab
          viols = intel.tabs?.violations?.[0]?.rows || [];
        } else {
          // Socrata fallback (when backend unavailable)
          [pAttrs, viols] = await Promise.all([
            fetchPlutoAttrs(geoResult.bbl).catch(() => null),
            fetchViolations(geoResult.bin).catch(() => []),
          ]);
        }

        setBuildings(bldgs);
        setPluto(pAttrs);
        setViolations(viols);
        setDataSource(intel ? "live" : "fallback");

        // Auto-select the searched building — BIN → point-in-polygon → closest centroid
        const main = bestBuildingMatch(bldgs, geoResult.lat, geoResult.lng, geoResult.bin);
        setSelectedOsmId(main?.osmId || null);
      } catch {
        setGeo({ lat: 40.6001, lng: -74.0090, ...BUILDING,
          bbl: bblNumeric(BUILDING.bbl), bin: String(BUILDING.bin) });
        setDataSource("fallback");
      } finally {
        setLoading(false);
      }
    }, 400);
    return () => clearTimeout(t);
  }, [query]);

  // Ref so handleBuildingClick is stable (never recreated on selection change)
  const selectedOsmIdRef = React.useRef(selectedOsmId);
  React.useEffect(() => { selectedOsmIdRef.current = selectedOsmId; }, [selectedOsmId]);

  // Abort token — incremented on every new click so stale async loads don't commit state
  const clickGenRef = React.useRef(0);

  // Click handler — select building + load its data
  // useCallback with empty deps so the function identity is stable across renders;
  // guards use refs to always read current values.
  const handleBuildingClick = React.useCallback(async (bldg) => {
    if (bldg.osmId === selectedOsmIdRef.current) return;

    // Claim this generation; any previous in-flight fetch will see a stale gen and bail
    const gen = ++clickGenRef.current;

    setSelectedOsmId(bldg.osmId); // polygon highlights on next React paint — fast
    setDataLoading(true);
    // Keep previous pluto/violations visible while loading — no blank flash

    try {
      let bin = bldg.bin || null;
      let bbl = bldg.bbl || null;

      // Reverse geocode centroid to get BBL (for PLUTO) and BIN (for HPD)
      if (!bin || !bbl) {
        const [clat, clng] = centroid(bldg.ring);
        const rev = await reverseGeocode(clat, clng).catch(() => null);
        if (gen !== clickGenRef.current) return; // superseded
        if (rev) { bin = bin || rev.bin; bbl = bbl || rev.bbl; }
      }

      setGeo(g => ({
        ...g,
        label: bldg.address || g?.label,
        bin:   bin || g?.bin,
        bbl:   bbl || g?.bbl,
      }));

      // Try backend intel first, then Socrata fallback
      const intel = await fetchBuildingIntel(bbl).catch(() => null);
      if (gen !== clickGenRef.current) return; // superseded

      let pAttrs, viols;
      if (intel) {
        pAttrs = intel.tabs?.overview?.[0]?.rows?.[0] || null;
        viols  = intel.tabs?.violations?.[0]?.rows || [];
      } else {
        [pAttrs, viols] = await Promise.all([
          fetchPlutoAttrs(bbl).catch(() => null),
          fetchViolations(bin).catch(() => []),
        ]);
        if (gen !== clickGenRef.current) return; // superseded
      }
      setPluto(pAttrs);
      setViolations(viols);
    } finally {
      // Only clear the loading spinner if we're still the active request
      if (gen === clickGenRef.current) setDataLoading(false);
    }
  }, []); // stable — no deps needed thanks to refs

  // Merge a newly-discovered building and select it
  const handleAddBuilding = React.useCallback((bldg) => {
    setBuildings(prev => prev.some(b => b.osmId === bldg.osmId) ? prev : [...prev, bldg]);
    handleBuildingClick(bldg);
  }, [handleBuildingClick]);

  // Stable coords: only creates a new object when lat/lng genuinely change.
  // Avoids optional-chaining in useMemo deps (Babel standalone edge case).
  const geoLat = geo ? geo.lat : null;
  const geoLng = geo ? geo.lng : null;
  const mapCoords = React.useMemo(
    () => (geoLat && geoLng ? { lat: geoLat, lng: geoLng } : null),
    [geoLat, geoLng]
  );

  // Pass coords only once loading is complete; the map itself is always alive
  useLeafletMap(containerRef, loading ? null : mapCoords, buildings, selectedOsmId, handleBuildingClick, handleAddBuilding, violations.length);

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100vh", paddingTop: 65 }}>
      <AppBar query={query} onQuery={onQuery} onSearch={onSearch} onHome={onHome} onMap={onMap} onDashboard={onDashboard} onSaved={onSaved}/>

      <div style={{ flex: 1, display: "flex", overflow: "hidden" }} className="map-shell">
        <div style={{ flex: 1, position: "relative" }}>
          <div ref={containerRef} style={{ position: "absolute", inset: 0 }}/>

          {loading && <MapLoadingOverlay query={query}/>}

          {!loading && (
            <>
              <div style={{ position: "absolute", bottom: 16, left: 16, zIndex: 10,
                background: "rgba(255,255,255,0.9)", backdropFilter: "blur(8px)",
                border: "1px solid var(--hp-border)", borderRadius: 6, padding: "5px 10px",
                fontFamily: "var(--hp-font-mono)", fontSize: 10, color: "var(--hp-text-2)",
                boxShadow: "var(--hp-elev-2)" }}>
                <span style={{ marginRight: 6, color: dataSource === "live" ? "var(--hp-risk-low)" : "var(--hp-text-3)" }}>●</span>
                {dataSource === "live" ? "MapPLUTO · HPD · DOB · 311 (api.nycintel.app)" : "Demo data"}
              </div>
              <div style={{ position: "absolute", top: 16, left: 16, zIndex: 10,
                background: "rgba(255,255,255,0.9)", backdropFilter: "blur(8px)",
                border: "1px solid var(--hp-border)", borderRadius: 6, padding: "6px 10px",
                fontSize: 12, fontWeight: 600, boxShadow: "var(--hp-elev-2)" }}>
                <span className="eyebrow" style={{ marginRight: 6 }}>Zone</span>
                {pluto?.zonedist1 || "—"} · {geo?.borough || "Brooklyn"}
              </div>
            </>
          )}
        </div>

        <Sidebar
          geo={geo} pluto={pluto} violations={violations}
          score={score} dataLoading={dataLoading}
          onUnlock={() => onUnlock(geo)} onFullReport={onFullReport}
        />
      </div>

      <style>{`
        @media (max-width: 860px) {
          .map-shell { flex-direction: column !important; }
          .map-shell > div:first-child { height: 45vh; flex: none !important; }
        }
        @keyframes hp-bar-slide {
          0%   { transform: translateX(-100%); }
          50%  { transform: translateX(0%); }
          100% { transform: translateX(100%); }
        }
      `}</style>
    </div>
  );
};

window.MapPage = MapPage;
