/* 200feet.net — Johnson City data-center setback explorer.
   Pure static. Renders eligible parcels on a Leaflet map and lets users
   measure how close a data center could legally be to any JC address. */

// Bumped whenever the data payload schema changes -- forces browsers to
// re-fetch JSON/GeoJSON instead of using stale cached copies.
const DATA_V = "8";
const v = (url) => url + (url.includes("?") ? "&" : "?") + "v=" + DATA_V;

// Palette mirrors the CSS :root variables. Keep in sync with style.css.
const C = {
  i2:        "#3b82f6",  // I-2 zoning
  strict:    "#ef4444",  // strict-eligible parcels (urgent red)
  buildable: "#fbbf24",  // buildable patches (amber)
  siteable:  "#f97316",  // building envelope (deeper orange)
  protected: "#22c55e",  // homes newly protected (green)
  marginal:  "#c084fc",  // DC sites lost (purple)
  you:       "#06b6d4",  // your address (cyan)
};

const STYLES = {
  i2zone:    { color: C.i2,        weight: 1,   fillColor: C.i2,        fillOpacity: 0.15 },
  i2parcel:  { color: "#98a1b3",   weight: 0.5, fillOpacity: 0 },
  strict:    { color: C.strict,    weight: 2.5, fillColor: C.strict,    fillOpacity: 0.35 },
  buildable: { color: C.buildable, weight: 1,   fillColor: C.buildable, fillOpacity: 0.45 },
  siteable:  { color: C.siteable,  weight: 1,   fillColor: C.siteable,  fillOpacity: 0.55 },
  marginal:  { color: C.marginal,  weight: 3,   fillOpacity: 0, dashArray: "8 6" },
};

const POINT_STYLES = {
  homesProtected: { radius: 4, fillColor: C.protected, color: "#15803d", weight: 1, fillOpacity: 0.80 },
};

const layerSpec = [
  { id: "layer-i2-zones",   url: "data/i2_zones.geojson",                  style: STYLES.i2zone,    popup: i2ZonePopup },
  { id: "layer-i2-parcels", url: "data/i2_parcels.geojson",                style: STYLES.i2parcel,  popup: parcelPopup },
  { id: "layer-buildable",  url: "data/eligible_{ft}ft_buildable.geojson", style: STYLES.buildable, popup: buildablePopup, perFt: true },
  { id: "layer-siteable",   url: "data/eligible_{ft}ft_siteable.geojson",  style: STYLES.siteable,  popup: buildablePopup, perFt: true },
  { id: "layer-strict",     url: "data/eligible_{ft}ft_strict.geojson",    style: STYLES.strict,    popup: strictPopup,    perFt: true },
  { id: "layer-marginal",   url: "data/marginal_parcels_500.geojson",      style: STYLES.marginal,  popup: marginalPopup, alwaysTop: true },
  { id: "layer-homes-protected", url: "data/homes_protected_500.geojson",  pointStyle: POINT_STYLES.homesProtected, popup: protectedHomePopup, alwaysTop: true },
];

const map = L.map("map", { center: [36.3134, -82.3535], zoom: 12 });
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
  maxZoom: 19,
  attribution: '&copy; <a href="https://openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);

const state = {
  ft: 200,
  layers: {},
  geojsonCache: {},
  addresses: null,
  addressesLoading: null,
  userMarker: null,
  nearestHighlight: null,
};

/* ---------- popup formatters ---------- */
function fmtAcres(a) { return (a == null) ? "—" : a.toFixed(2) + " ac"; }
function fmtFt(n) { return (n == null) ? "—" : Math.round(n).toLocaleString() + " ft"; }
function i2ZonePopup(p) { return `<b>${p.ZONECLASS || "I-2"}</b><br>${p.ZONEDESC || ""}`; }
function parcelPopup(p) {
  return `<b>${p.ADDRESS || "(no address)"}</b>` +
         `<br>Owner: ${p.OWNER || "—"}` +
         `<br>Zone: ${p.ZONECLASS || "—"} · Land use: ${p.LANDUSE || "—"}` +
         `<br>Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}`;
}
function buildablePopup(p) {
  const ft = state.ft;
  return `<b>${p.ADDRESS || "(no address)"}</b>` +
         `<br>Owner: ${p.OWNER || "—"}` +
         `<br>Largest contig patch: <b>${fmtAcres(p["max_contig_acres_" + ft])}</b>` +
         `<br>Building envelope: ${fmtAcres(p["max_siteable_acres_" + ft])}` +
         `<br>Min building width: <b>${fmtFt(p["inscribed_diam_ft_" + ft])}</b>` +
         `<br>Public road frontage: <b>${fmtFt(p["patch_frontage_ft_" + ft])}</b>`;
}
function strictPopup(p) {
  const ft = state.ft;
  return `<b>${p.ADDRESS || "(no address)"}</b>` +
         `<br>Owner: ${p.OWNER || "—"}` +
         `<br>Whole parcel passes the ${ft} ft test.` +
         `<br>Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}` +
         `<br>Min building width: <b>${fmtFt(p["inscribed_diam_ft_" + ft])}</b>`;
}
function marginalPopup(p) {
  return `<b>${p.ADDRESS || "(no address)"}</b>` +
         `<br>Owner: ${p.OWNER || "—"}` +
         `<br><span style="color:${C.marginal}">Eligible at 200 ft. Lost if setback raised to 500 ft.</span>` +
         `<br>Buildable @ 200 ft: ${fmtAcres(p.max_contig_acres_200)}`;
}
// Anonymize a house-level address to the 100-block to avoid individually
// fingerprinting a single home in the popup. "414 BRICE LN" -> "400 block of
// BRICE LN". Leaves addresses without a leading numeric house number alone.
function anonymizeAddress(addr) {
  if (!addr) return "(no address)";
  const m = String(addr).trim().match(/^(\d+)\s+(.+)$/);
  if (!m) return String(addr);
  const num = parseInt(m[1], 10);
  if (!Number.isFinite(num) || num <= 0) return String(addr);
  const block = Math.floor(num / 100) * 100;
  return `${block} block of ${m[2]}`;
}

function protectedHomePopup(p) {
  return `<b>${anonymizeAddress(p.FULLADDR)}</b>` +
         `<br>Type: ${p.PLACETYPE || "—"}` +
         `<br>Nearest DC site @ 200 ft setback: <b>${fmtFt(p.dist_200_ft)}</b>` +
         `<br>Nearest DC site @ 500 ft setback: <b>${fmtFt(p.dist_500_ft)}</b>` +
         `<br><span style="color:${C.protected}">Newly outside the 1,000 ft exposure ring if setback raised to 500 ft.</span>`;
}

/* ---------- data loading ---------- */
async function getGeoJSON(url) {
  if (!state.geojsonCache[url]) {
    const p = fetch(v(url)).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status} ${url}`)));
    // Don't keep a rejected promise in cache -- retry on next call.
    p.catch(() => { delete state.geojsonCache[url]; });
    state.geojsonCache[url] = p;
  }
  return state.geojsonCache[url];
}

async function renderLayer(spec) {
  if (state.layers[spec.id]) { map.removeLayer(state.layers[spec.id]); delete state.layers[spec.id]; }
  const checkbox = document.getElementById(spec.id);
  if (!checkbox || !checkbox.checked) return;
  const url = spec.url.replace("{ft}", state.ft);
  let gj;
  try { gj = await getGeoJSON(url); }
  catch (e) { console.warn("missing", url); return; }
  const opts = {
    onEachFeature: (feature, lyr) => lyr.bindPopup(spec.popup(feature.properties)),
  };
  if (spec.style) opts.style = spec.style;
  if (spec.pointStyle) {
    opts.pointToLayer = (feature, latlng) => L.circleMarker(latlng, spec.pointStyle);
  }
  const layer = L.geoJSON(gj, opts);
  layer.addTo(map);
  if (spec.alwaysTop) layer.bringToFront();
  state.layers[spec.id] = layer;
}

async function renderAll() { for (const spec of layerSpec) await renderLayer(spec); }

/* ---------- controls ---------- */
function wireSetbackButtons() {
  document.querySelectorAll(".seg-btn").forEach(btn => {
    btn.addEventListener("click", async () => {
      document.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active"));
      btn.classList.add("active");
      state.ft = parseInt(btn.dataset.ft, 10);
      for (const spec of layerSpec) if (spec.perFt) await renderLayer(spec);
      // Keep marginal layer on top after re-render
      const mLayer = state.layers["layer-marginal"];
      if (mLayer) mLayer.bringToFront();
      if (state.userMarker) refreshDistance();
    });
  });
}

function wireLayerCheckboxes() {
  layerSpec.forEach(spec => {
    const cb = document.getElementById(spec.id);
    if (cb) cb.addEventListener("change", () => renderLayer(spec));
  });
  // Mirror the case-section homes-protected toggle to the layer-menu one.
  pairMirror("layer-homes-protected", "layer-homes-protected-2");
}

function pairMirror(primaryId, mirrorId) {
  const primary = document.getElementById(primaryId);
  const mirror = document.getElementById(mirrorId);
  if (!primary || !mirror) return;
  mirror.checked = primary.checked;
  mirror.addEventListener("change", () => {
    if (primary.checked !== mirror.checked) {
      primary.checked = mirror.checked;
      primary.dispatchEvent(new Event("change"));
    }
  });
  primary.addEventListener("change", () => {
    if (mirror.checked !== primary.checked) mirror.checked = primary.checked;
  });
}

function wireLayerMenu() {
  // The menu trigger lives inside <details>; the panel itself lives outside
  // (as a sibling of the map) so opening the menu pushes the map down rather
  // than overlapping it. We show/hide the panel based on the details state.
  const menu = document.querySelector(".layer-menu");
  const panel = document.getElementById("menu-panel");
  if (!menu || !panel) return;
  const sync = () => { panel.hidden = !menu.open; };
  menu.addEventListener("toggle", sync);
  sync();
}

/* ---------- address checker ---------- */
async function loadAddresses() {
  if (state.addresses) return state.addresses;
  if (state.addressesLoading) return state.addressesLoading;
  state.addressesLoading = fetch(v("data/addresses.json")).then(r => r.json()).then(arr => {
    state.addresses = arr.map(([a, lat, lng]) => [a, lat, lng, a.toLowerCase()]);
    return state.addresses;
  });
  return state.addressesLoading;
}

const debounce = (fn, ms) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; };

const addrInput = document.getElementById("addr-input");
const addrSuggest = document.getElementById("addr-suggest");
const addrGo = document.getElementById("addr-go");
const addrResult = document.getElementById("addr-result");
let suggestActive = -1;
let suggestItems = [];

const onType = debounce(async (val) => {
  if (!val || val.length < 2) { addrSuggest.hidden = true; return; }
  const arr = await loadAddresses();
  const q = val.toLowerCase();
  const tokens = q.split(/\s+/).filter(Boolean);
  const out = [];
  for (let i = 0; i < arr.length && out.length < 12; i++) {
    const lc = arr[i][3];
    let ok = true;
    for (const t of tokens) if (lc.indexOf(t) < 0) { ok = false; break; }
    if (ok) out.push(arr[i]);
  }
  suggestItems = out;
  if (!out.length) { addrSuggest.hidden = true; return; }
  addrSuggest.innerHTML = out.map((row, i) => `<li data-i="${i}">${escapeHtml(row[0])}</li>`).join("");
  addrSuggest.hidden = false;
  suggestActive = -1;
}, 80);

addrInput.addEventListener("input", e => onType(e.target.value));
addrInput.addEventListener("keydown", e => {
  if (addrSuggest.hidden) return;
  if (e.key === "ArrowDown") { e.preventDefault(); moveSuggest(1); }
  else if (e.key === "ArrowUp") { e.preventDefault(); moveSuggest(-1); }
  else if (e.key === "Enter") { e.preventDefault(); pickSuggest(suggestActive >= 0 ? suggestActive : 0); }
  else if (e.key === "Escape") addrSuggest.hidden = true;
});
addrSuggest.addEventListener("click", e => {
  const li = e.target.closest("li");
  if (li) pickSuggest(parseInt(li.dataset.i, 10));
});
addrGo.addEventListener("click", () => { if (suggestItems[0]) pickSuggest(0); });
document.addEventListener("click", e => { if (!e.target.closest(".checker")) addrSuggest.hidden = true; });

function moveSuggest(delta) {
  suggestActive = (suggestActive + delta + suggestItems.length) % suggestItems.length;
  [...addrSuggest.children].forEach((li, i) => li.classList.toggle("active", i === suggestActive));
}

function pickSuggest(i) {
  const row = suggestItems[i];
  if (!row) return;
  addrInput.value = row[0];
  addrSuggest.hidden = true;
  placeUser(row[0], row[1], row[2]);
}

function placeUser(addr, lat, lng) {
  if (state.userMarker) map.removeLayer(state.userMarker);
  state.userMarker = L.circleMarker([lat, lng], {
    color: "#ffffff", weight: 2, fillColor: C.you, fillOpacity: 1, radius: 8,
  }).bindPopup(`<b>${escapeHtml(addr)}</b>`).addTo(map);
  map.setView([lat, lng], 15);
  // Show a loading state immediately so the user knows something is happening.
  addrResult.hidden = false;
  addrResult.innerHTML = `<div class="big" style="color:var(--ink-dim)">Measuring…</div>`;
  // Guarantee the loading state is replaced even if refreshDistance throws.
  refreshDistance().catch(err => {
    console.error("refreshDistance failed", err);
    addrResult.innerHTML = `<div class="big">Error</div>
      <div class="little">Couldn't measure distance: ${escapeHtml(err && err.message ? err.message : String(err))}.<br>
      Open the browser console for details.</div>`;
  });
}

// Distance (km) from a point to the nearest edge of a polygon feature.
// Handles Polygon, MultiPolygon, and GeometryCollection. Returns 0 if the
// point is inside the polygon.
function distancePointToFeatureKm(point, feature) {
  if (!feature || !feature.geometry) return Infinity;
  const gtype = feature.geometry.type;
  if (gtype === "GeometryCollection") {
    let min = Infinity;
    for (const g of feature.geometry.geometries) {
      const d = distancePointToFeatureKm(point, { type: "Feature", geometry: g, properties: feature.properties });
      if (d < min) min = d;
    }
    return min;
  }
  if (gtype === "MultiPolygon") {
    let min = Infinity;
    for (const polyCoords of feature.geometry.coordinates) {
      const polyFeat = turf.polygon(polyCoords, feature.properties);
      const d = distancePointToFeatureKm(point, polyFeat);
      if (d < min) min = d;
    }
    return min;
  }
  if (gtype !== "Polygon") return Infinity;
  try {
    if (turf.booleanPointInPolygon(point, feature)) return 0;
    // polygonToLine on a Polygon returns Feature<LineString> (outer ring) for
    // simple polygons, or Feature<MultiLineString> if there are holes. Both
    // are valid inputs to pointToLineDistance.
    const line = turf.polygonToLine(feature);
    // turf 7 polygonToLine sometimes returns a FeatureCollection -- normalize.
    if (line.type === "FeatureCollection") {
      let min = Infinity;
      for (const f of line.features) {
        const d = turf.pointToLineDistance(point, f, { units: "kilometers" });
        if (d < min) min = d;
      }
      return min;
    }
    return turf.pointToLineDistance(point, line, { units: "kilometers" });
  } catch (e) {
    console.warn("distance fallback for feature", e);
    // Manual fallback: nearest vertex.
    let min = Infinity;
    turf.coordEach(feature, (coord) => {
      const d = turf.distance(point, turf.point(coord), { units: "kilometers" });
      if (d < min) min = d;
    });
    return min;
  }
}

async function refreshDistance() {
  const m = state.userMarker;
  if (!m) return;
  const userPoint = turf.point([m.getLatLng().lng, m.getLatLng().lat]);

  // Compute nearest at all three setbacks so the user gets a comparison.
  const setbacks = [200, 500, 1000];
  const results = {};
  for (const ft of setbacks) {
    let gj;
    try { gj = await getGeoJSON(`data/eligible_${ft}ft_buildable.geojson`); }
    catch (e) { results[ft] = { empty: true }; continue; }
    if (!gj || !gj.features || !gj.features.length) { results[ft] = { empty: true }; continue; }
    let best = null;
    for (const feat of gj.features) {
      let d;
      try { d = distancePointToFeatureKm(userPoint, feat); }
      catch (err) { console.warn("skipped feature", err); continue; }
      if (best === null || d < best.d) best = { d, feat };
    }
    results[ft] = best ? { ftDist: best.d * 3280.84, feat: best.feat } : { empty: true };
  }

  const active = results[state.ft];
  addrResult.hidden = false;

  // Headline section -- nearest at the currently selected setback
  let headline;
  if (!active) {
    headline = `<div class="big" style="color:var(--ink-dim)">No data</div>`;
  } else if (active.empty) {
    headline = `<div class="big">No sites at ${state.ft} ft</div>
      <div class="little">A ${state.ft} ft setback eliminates every possible data center site in Johnson City.</div>`;
  } else if (active.ftDist === 0) {
    const p = active.feat.properties;
    headline = `<div class="big">Inside a buildable site</div>
      <div class="little">Your address sits within an area where a data center could legally be sited under a ${state.ft} ft setback.<br>
      Patch: <b>${escapeHtml(p.ADDRESS || "(no address)")}</b> · owner: <b>${escapeHtml(p.OWNER || "—")}</b>.</div>`;
  } else {
    const ft = active.ftDist;
    const p = active.feat.properties;
    const miles = ft >= 5280 ? ` <span class="muted">(${(ft/5280).toFixed(2)} mi)</span>` : "";
    headline = `<div class="big">${Math.round(ft).toLocaleString()} ft${miles}</div>
      <div class="little">to the nearest place a data center could legally be sited under a <b>${state.ft} ft</b> setback.<br>
      Nearest site: <b>${escapeHtml(p.ADDRESS || "(no address)")}</b> · owner: <b>${escapeHtml(p.OWNER || "—")}</b>.</div>`;
  }

  // Comparison row: nearest at each setback so the user sees the change.
  const cells = setbacks.map(ft => {
    const r = results[ft];
    let v;
    if (!r) v = "—";
    else if (r.empty) v = '<span class="muted">none</span>';
    else if (r.ftDist === 0) v = "inside";
    else v = `${Math.round(r.ftDist).toLocaleString()} ft`;
    const cls = ft === state.ft ? "cmp-cell active" : "cmp-cell";
    return `<div class="${cls}"><div class="cmp-lbl">${ft} ft setback</div><div class="cmp-val">${v}</div></div>`;
  }).join("");
  const compare = `<div class="cmp-row">${cells}</div>`;

  addrResult.innerHTML = headline + compare;

  // Highlight the nearest site for the currently selected setback.
  if (state.nearestHighlight) { map.removeLayer(state.nearestHighlight); state.nearestHighlight = null; }
  if (active && !active.empty && active.feat) {
    state.nearestHighlight = L.geoJSON(active.feat, {
      style: { color: C.you, weight: 4, fillColor: C.you, fillOpacity: 0.20 },
    }).addTo(map);
  }
}

/* ---------- headlines / comparison ---------- */
async function loadHeadlines() {
  const m = await (await fetch(v("data/manifest.json"))).json();
  const h = m.headlines || {};
  document.getElementById("stat-strict-200").textContent = h["200ft_strict"] ?? "—";
  document.getElementById("stat-buildable-200").textContent = h["200ft_buildable"] ?? "—";
}

async function loadComparison() {
  let c;
  try { c = await (await fetch(v("data/comparison.json"))).json(); }
  catch (e) { console.warn("no comparison.json"); return; }
  const s200 = c.setback_200, s500 = c.setback_500, s1000 = c.setback_1000;
  const prot500 = c.protection.to_500.newly_protected_within_ft;
  // Drop the 200ft bucket (all zeros by construction at every setback).
  const buckets = c.buckets_ft.filter(b => b >= 500);
  const fmt = n => n.toLocaleString();
  const cell = (n) => `<td${n === 0 ? ' class="zero"' : ""}>${fmt(n)}</td>`;
  const labelFor = b => b >= 5280 ? "1 mile"
                     : b >= 2640 ? "½ mile"
                     : `${b.toLocaleString()} ft`;
  const tbody = document.querySelector("#case-table tbody");
  tbody.innerHTML = buckets.map(b => {
    const e200  = s200.homes_within_ft[b]  ?? 0;
    const e500  = s500.homes_within_ft[b]  ?? 0;
    const e1000 = s1000.homes_within_ft[b] ?? 0;
    return `<tr><td>${labelFor(b)}</td>${cell(e200)}${cell(e500)}${cell(e1000)}</tr>`;
  }).join("");
  document.getElementById("case-remaining-500").textContent = s500.buildable_patches;
  document.getElementById("case-remaining-1000").textContent = s1000.buildable_patches;
  const homes1k = s200.homes_within_ft["1000"];
  if (homes1k != null) document.getElementById("stat-homes-1k").textContent = homes1k.toLocaleString();
  const protectedCount = prot500["1000"];
  const pcEl = document.getElementById("protected-count");
  if (pcEl && protectedCount != null) pcEl.textContent = protectedCount.toLocaleString();
}

const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

/* ---------- boot ---------- */
(async function init() {
  wireSetbackButtons();
  wireLayerCheckboxes();
  wireLayerMenu();
  await loadHeadlines();
  await loadComparison();
  await renderAll();
  setTimeout(() => loadAddresses(), 800);
})();
