// Compare view: line up several places over one date range and see which best
// fits a comfort temperature *range*. For each location we pull the same daily
// record the Calendar uses (api/v2/calendar) and, per day, take a chosen
// temperature (daytime high / daily mean / overnight low / feels-like) and test it
// against the comfort range [lo, hi]: inside the range "hits comfort"; below lo is
// colder, above hi is warmer, and we track by how much.
//
// Below the ranked cards a separate climate-distribution section shows how each
// place's days spread across a metric's Record-Low→Record-High categories (its own
// metric + share/count toggles), reusing the calendar's totals strip.
//
// The comparison — locations, comfort range, judged temperature and date range —
// lives in the URL hash, so a link reproduces exactly what you see. The range and
// judged-temperature re-rank instantly (all values are already in hand). Editing
// the date range or the location set doesn't refetch on its own: it arms the
// Refresh button, and the load runs when the user taps it.

import { loadLastLocation, saveLastLocation } from "./nav.js";
import { getJSON, TTL } from "./cache.js";
import { initFindButton } from "./mappicker.js";
import { MONTHS, pad, monthStart, monthEnd, buildChunks,
         clickOpensPicker, metricBuckets, distStrip,
         seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
         applySeasonMonthChange, initSeasonExpand,
         allMonths, monthsToMask, maskToMonths,
         namePrimary, nameParts } from "./shared.js";
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
import { uv } from "./account.js";   // header sign-in entry + notification bell, and the API-version resolver
import { initFilterSheet } from "./filtersheet.js";

const CMP = {
  lo: "thermograph:cmpLo",
  hi: "thermograph:cmpHi",
  tlo: "thermograph:cmpTlo",
  thi: "thermograph:cmpThi",
  basis: "thermograph:cmpBasis",
  range: "thermograph:cmpRange",
  months: "thermograph:cmpMonths",
  locs: "thermograph:cmpLocs",
  distMetric: "thermograph:cmpDistMetric",
  distCount: "thermograph:cmpDistCount",
};

const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };

const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const BASES = ["tmax", "tmin", "mean", "feels"];
const R_MIN = 30, R_MAX = 100;   // comfort-range slider bounds (°F)

// The comfort range [lo, hi] in °F. Restored from the new lo/hi keys, else migrated
// from the old comfort-point + band (c ± t), else the default 63–73° (was 68 ± 5).
function loadRange2() {
  let lo = +lsGet(CMP.lo), hi = +lsGet(CMP.hi);
  if (lo >= R_MIN && hi <= R_MAX && lo <= hi) return { lo, hi };
  const c = +lsGet("thermograph:cmpComfort"), t = +lsGet("thermograph:cmpTol");
  if (c >= R_MIN && c <= R_MAX && t >= 0) return { lo: clamp(c - t, R_MIN, R_MAX), hi: clamp(c + t, R_MIN, R_MAX) };
  return { lo: 63, hi: 73 };
}
let { lo, hi } = loadRange2();

// Outer tolerance range [tlo, thi], with tlo ≤ lo ≤ hi ≤ thi. Days outside comfort
// but inside tolerance still count toward the match score (ramped by how far past
// comfort they land); days beyond tolerance count as a full miss. Restored from
// storage, else defaulting to comfort widened by 10° on each side.
function loadTol() {
  const t = { tlo: +lsGet(CMP.tlo), thi: +lsGet(CMP.thi) };
  if (t.tlo >= R_MIN && t.thi <= R_MAX && t.tlo <= lo && t.thi >= hi) return t;
  return { tlo: clamp(lo - 10, R_MIN, R_MAX), thi: clamp(hi + 10, R_MIN, R_MAX) };
}
let { tlo, thi } = loadTol();
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";

// Time-of-year filter: only days whose month is checked count toward the ranking and
// the distribution. Seasons are the primary selector, months the nested suboption;
// the single source of truth is this Set of month indices (0=Jan … 11=Dec). Restored
// from a 12-char bitmask, defaulting to all 12 months (no filtering).
function loadMonths() {
  const m = maskToMonths(lsGet(CMP.months));
  return m && m.size ? m : allMonths();
}
let checkedMonths = loadMonths();
const monthsActive = () => checkedMonths.size < 12;
const monthOf = (r) => +r.date.slice(5, 7) - 1;
// A location's days narrowed to the checked months (pass-through when all 12 are on).
function visibleSeries(loc) {
  if (!loc.series) return null;
  if (!monthsActive()) return loc.series;
  return loc.series.filter((r) => r.date && checkedMonths.has(monthOf(r)));
}

// Distribution section (independent of comfort): which metric to bucket by, and
// whether the strip prints shares or raw day counts.
const DIST_METRICS = ["tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dsr"];
let distMetric = DIST_METRICS.includes(lsGet(CMP.distMetric)) ? lsGet(CMP.distMetric) : "tmax";
let distCount = lsGet(CMP.distCount) === "1";

// Locations being compared: {lat, lon, name, series|null, loading, error}.
// `series` is the array of raw daily records from /api/v2/calendar (each metric an
// {v, c, …} object) — the comfort ranking reads the values, the distribution the
// category classes. No series and not loading is "pending" — it needs a Refresh tap.
let locations = [];
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
let loadToken = 0;

const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like high" };

// ---- date range (month granularity) ----
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
function defaultRange() {
  const now = new Date();
  // January six years back → the present month (a broad seasonal sample by default).
  return { start: `${now.getFullYear() - 6}-01`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
}
function loadRange() {
  try { const o = JSON.parse(lsGet(CMP.range)); if (o && isYM(o.start) && isYM(o.end)) return o; } catch (e) {}
  return defaultRange();
}
let range = loadRange();   // the APPLIED range (what loaded data reflects), "YYYY-MM"

// Pretty "Jul 2025 – Jun 2026" for a YYYY-MM range.
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;

// ---- shareable URL state ----
// The hash carries the comparison: lo/hi=comfort range, b=basis, s/e=range,
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
// wins over localStorage). Old links used c=comfort + t=band; those still resolve
// (converted to a range). A plain lat/lon hash from cross-view nav is ignored here
// and handled by the seed path instead.
function writeHashState() {
  const p = new URLSearchParams();
  p.set("lo", lo); p.set("hi", hi); p.set("tlo", tlo); p.set("thi", thi); p.set("b", basis);
  p.set("s", range.start); p.set("e", range.end);
  if (monthsActive()) p.set("m", monthsToMask(checkedMonths));   // omit when all 12 (clean links)
  if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
  history.replaceState(null, "", "#" + p.toString());
}
function readHashState() {
  const p = new URLSearchParams(location.hash.slice(1));
  if (!["lo", "hi", "tlo", "thi", "c", "t", "b", "s", "e", "m", "loc"].some((k) => p.has(k))) return null;   // not a compare link
  const st = {};
  if (p.has("lo") || p.has("hi")) {
    const l = clamp(+p.get("lo"), R_MIN, R_MAX), h = clamp(+p.get("hi"), R_MIN, R_MAX);
    if (!isNaN(l) && !isNaN(h)) { st.lo = Math.min(l, h); st.hi = Math.max(l, h); }
  } else if (p.has("c")) {   // legacy comfort-point + band link
    const c = clamp(+p.get("c") || 68, R_MIN, R_MAX);
    const t = p.get("t") !== null && p.get("t") !== "" ? clamp(+p.get("t"), 0, 15) : 5;
    st.lo = clamp(c - t, R_MIN, R_MAX); st.hi = clamp(c + t, R_MIN, R_MAX);
  }
  if (p.has("tlo") || p.has("thi")) {
    const a = clamp(+p.get("tlo"), R_MIN, R_MAX), b = clamp(+p.get("thi"), R_MIN, R_MAX);
    if (!isNaN(a)) st.tlo = a;
    if (!isNaN(b)) st.thi = b;
  }
  if (BASES.includes(p.get("b"))) st.basis = p.get("b");
  if (isYM(p.get("s"))) st.start = p.get("s");
  if (isYM(p.get("e"))) st.end = p.get("e");
  if (p.has("m")) { const ms = maskToMonths(p.get("m")); if (ms && ms.size) st.months = ms; }
  if (p.has("loc")) {
    st.locs = p.get("loc").split(";").map((pair) => {
      const [a, b] = pair.split(",").map(Number);
      return { lat: a, lon: b };
    }).filter((l) => !isNaN(l.lat) && !isNaN(l.lon));
  }
  return st;
}

// ---- elements ----
const addBtn = document.getElementById("cmp-add");
const refreshBtn = document.getElementById("cmp-refresh");
// A second Load/Refresh lives inside the params sheet's date-range block, so editing
// the range on mobile (where the panel is a bottom sheet) can reload without closing.
const refreshBtnSheet = document.getElementById("cmp-refresh-sheet");
const refreshBtns = [refreshBtn, refreshBtnSheet];
const locList = document.getElementById("cmp-loc-list");
const params = document.getElementById("cmp-params");
const comfortWrap = document.getElementById("cmp-comfort");   // now inline, above the sheet
const placeholder = document.getElementById("cmp-placeholder");
const head = document.getElementById("cmp-head");
const baseline = document.getElementById("cmp-baseline");
const results = document.getElementById("cmp-results");
const loInput = document.getElementById("cmp-lo");
const hiInput = document.getElementById("cmp-hi");
const tloInput = document.getElementById("cmp-tlo");
const thiInput = document.getElementById("cmp-thi");
const comfortValEl = document.getElementById("cmp-comfort-val");
const tolValEl = document.getElementById("cmp-tol-val");
const tickEls = {
  tlo: document.getElementById("cmp-tick-tlo"), lo: document.getElementById("cmp-tick-lo"),
  hi: document.getElementById("cmp-tick-hi"), thi: document.getElementById("cmp-tick-thi"),
};
const fillEl = document.getElementById("cmp-fill");
const fillTolEl = document.getElementById("cmp-fill-tol");
const basisToggle = document.getElementById("cmp-basis");
const seasonDd = document.getElementById("cmp-season-dd");
const startInput = document.getElementById("cmp-start");
const endInput = document.getElementById("cmp-end");
const distSection = document.getElementById("cmp-dist");
const distToggle = document.getElementById("cmp-dist-metric");
const distToggleSheet = document.getElementById("cmp-dist-metric-sheet");
// The sheet copy mirrors the inline selector's 8 buttons (cloned) and stays in sync.
distToggleSheet.innerHTML = distToggle.innerHTML;
const distBody = document.getElementById("cmp-dist-body");


// ---- data ----
// Fetch one location's range (chunked), keeping the raw daily records — the comfort
// ranking reads their values, the distribution their category classes.
async function fetchSeries(loc, token) {
  const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
  const days = [];
  let place = null;
  for (const ch of chunks) {
    const url = uv(`calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`);
    const d = await getJSON(url, TTL.calendar, true);
    if (token !== loadToken) return null;   // superseded
    place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
    for (const r of d.days) days.push(r);
  }
  return { name: place, series: days };
}

function persistLocations() {
  lsSet(CMP.locs, JSON.stringify(locations.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name }))));
}

async function loadLocation(loc, token) {
  loc.loading = true; loc.error = null;
  renderAll();
  try {
    const res = await fetchSeries(loc, token);
    if (!res || token !== loadToken) return;   // superseded (a newer load owns this loc)
    loc.name = res.name; loc.series = res.series;
  } catch (e) {
    if (token === loadToken) loc.error = e.message || "couldn't load";
  }
  if (token === loadToken) loc.loading = false;
  persistLocations();
  renderAll();
}

// Load every location that still needs data under the current range.
function loadPending(token) {
  for (const loc of locations) if (!loc.series && !loc.loading) loadLocation(loc, token);
}

function addLocation(lat, lon) {
  // Ignore a spot already on the list (same grid neighborhood).
  if (locations.some((l) => Math.abs(l.lat - lat) < 0.05 && Math.abs(l.lon - lon) < 0.05)) return;
  const loc = { lat, lon, name: null, series: null, loading: false, error: null };
  locations.push(loc);
  saveLastLocation(lat, lon);
  persistLocations();
  writeHashState();
  renderAll();   // pending → the Refresh button appears; no fetch until it's tapped
  resolveName(loc);   // but resolve the neighborhood+city right away (before Refresh)
}

// Resolve just the place name for a freshly-added location, independent of its
// (deferred, heavier) series load, so the chip flips from coordinates to the
// neighborhood + city immediately on add. Best-effort — the series load resolves
// the name too, so a failure here is harmless.
async function resolveName(loc) {
  try {
    const res = await fetch(uv(`place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" });
    if (!res.ok) return;
    const d = await res.json();
    // Skip if the location was removed meanwhile, or already got named by a load.
    if (!locations.includes(loc) || loc.name || !d || !d.place) return;
    loc.name = d.place;
    persistLocations();
    renderAll();
  } catch (e) { /* best-effort — the series load will still resolve the name */ }
}

function removeLocation(i) {
  locations.splice(i, 1);
  persistLocations();
  writeHashState();
  renderAll();
}

// ---- pending / refresh state ----
const dateChanged = () => startInput.value !== range.start || endInput.value !== range.end;
const isPending = (l) => !l.series && !l.loading && !l.error;
const isDirty = () => locations.length > 0 && (dateChanged() || locations.some(isPending));

// The Refresh tap: adopt any edited dates (which invalidates every series) and load
// whatever now needs data.
function refresh() {
  let s = startInput.value || range.start, e = endInput.value || range.end;
  if (!isYM(s) || !isYM(e)) return;
  if (s > e) { const t = s; s = e; e = t; }
  const rangeChanged = s !== range.start || e !== range.end;
  range = { start: s, end: e };
  startInput.value = s; endInput.value = e;
  lsSet(CMP.range, JSON.stringify(range));
  const token = ++loadToken;
  if (rangeChanged) for (const loc of locations) loc.series = null;
  writeHashState();
  loadPending(token);
  renderAll();
}

// ---- stats ----
// The judged temperature for one raw daily record, per the basis toggle.
function basisTemp(r) {
  const hi = r.tmax ? r.tmax.v : null, lo = r.tmin ? r.tmin.v : null;
  if (basis === "tmax") return hi;
  if (basis === "tmin") return lo;
  // "Feels" judges the felt daytime high (fmax) — the apparent-temperature peak of
  // the day. The combined `feels` metric instead reports whichever apparent extreme
  // is furthest from 65°F, so in mild climates it flips to the cold overnight low in
  // winter and makes comfortable days vanish; fmax is the like-for-like daytime feel.
  if (basis === "feels") return r.fmax ? r.fmax.v : null;
  return hi != null && lo != null ? (hi + lo) / 2 : null;   // mean
}

// A quantile (0–1) of an ascending-sorted array, linearly interpolated.
function quantile(sorted, q) {
  if (!sorted.length) return null;
  const i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i);
  return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}

// Score every day 0–1: full credit inside comfort [lo,hi]; inside tolerance it
// ramps 1→0 by how far past the comfort edge it lands (so a near miss beats a far
// one); beyond tolerance it's 0. The mean of those credits is the 0–100 match
// score we rank on — it folds in comfort days, tolerance days, and miss magnitude.
function computeStats(series) {
  let comf = 0, belowTol = 0, belowOut = 0, aboveTol = 0, aboveOut = 0;
  let sumBelow = 0, sumAbove = 0, sumMiss = 0, sumTemp = 0, sumCredit = 0;
  const temps = [];   // every valid basis temp (°F), for the average + spread
  for (const r of series) {
    const t = basisTemp(r);
    if (t == null || isNaN(t)) continue;
    temps.push(t); sumTemp += t;
    if (t >= lo && t <= hi) { comf++; sumCredit += 1; continue; }
    const cold = t < lo;
    const d = cold ? lo - t : t - hi;             // degrees past the comfort edge
    const margin = cold ? lo - tlo : thi - hi;    // tolerance width on this side
    sumMiss += d;
    if (cold) sumBelow += d; else sumAbove += d;
    if (margin > 0 && d <= margin) {              // within tolerance: partial credit
      sumCredit += 1 - d / margin;
      if (cold) belowTol++; else aboveTol++;
    } else {                                      // beyond tolerance: a full miss
      if (cold) belowOut++; else aboveOut++;
    }
  }
  const n = temps.length;
  if (!n) return null;
  temps.sort((a, b) => a - b);
  const below = belowTol + belowOut, above = aboveTol + aboveOut;
  return {
    n, comf, below, above, belowTol, belowOut, aboveTol, aboveOut,
    score: 100 * sumCredit / n,                   // the match score we rank on
    comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
    belowTolPct: 100 * belowTol / n, belowOutPct: 100 * belowOut / n,
    aboveTolPct: 100 * aboveTol / n, aboveOutPct: 100 * aboveOut / n,
    tolPct: 100 * (belowTol + aboveTol) / n,
    avgBelow: below ? sumBelow / below : 0,
    avgAbove: above ? sumAbove / above : 0,
    mad: sumMiss / n,
    // Baseline strip: the typical temperature (°F) and the middle-80% spread.
    avgTemp: sumTemp / n, p10: quantile(temps, 0.1), p90: quantile(temps, 0.9),
  };
}

// ---- render ----
const pct = (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`;
// Temperature *differences* (avg miss, typical miss) in the active unit.
const deg = (v) => fmtDelta(v);

function renderLocList() {
  locList.innerHTML = locations.map((l, i) => {
    let label, cls = "cmp-chip", lead = "";
    // Coordinates show until the place name resolves. A spinning ring marks a live
    // load; a "queued" dot marks a location waiting for the next Refresh tap. Once
    // scored, l.name holds the resolved neighborhood + city.
    const coords = `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
    if (l.loading) { label = l.name || coords; cls += " loading"; lead = `<span class="cmp-spinner cmp-spinner-chip" aria-hidden="true"></span>`; }
    else if (l.error) { label = "Couldn't load"; cls += " error"; }
    else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; }
    else { label = l.name; }
    // The chip shows just the lead segment (compact, several per row); the full name
    // stays in the title + aria-label and on the ranked card below.
    return `<li class="${cls}">${lead}<span class="cmp-chip-name" title="${label}">${namePrimary(label)}</span>` +
           `<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`;
  }).join("");
}

// The five day-buckets, ordered cold → hot: css color class, plain-language name,
// and the stat key holding its share of days. Drives both the bar and its key.
const BUCKETS = [
  ["cmp-seg-vcold", "Too cold", "belowOutPct"],
  ["cmp-seg-cool", "Cool", "belowTolPct"],
  ["cmp-seg-comf", "Comfortable", "comfortPct"],
  ["cmp-seg-warm", "Warm", "aboveTolPct"],
  ["cmp-seg-vhot", "Too hot", "aboveOutPct"],
];

// A ranked place: the 0–100 match score up top, then one full-width bar showing how
// the place's days split across the five temperature buckets (cold → hot, in the
// site's own colors), and directly beneath it a key that names every bucket and its
// share — so even a 1% sliver on the bar reads clearly. The whole bar is 100% of the
// days, so more blue = runs cold, more red = runs hot.
// The place name as a hierarchy: the lead segment bold, the rest (city · region ·
// country) on a muted line beneath — the one spot that shows the full name.
function nameHierarchy(name) {
  const { primary, rest } = nameParts(name);
  return `<span class="cmp-name"><b>${primary}</b>${rest ? `<span class="cmp-name-sub">${rest}</span>` : ""}</span>`;
}

function rankCard(loc, s, rank) {
  const win = rank === 1;
  const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0);
  const bar = `<div class="cmp-bar" role="img" aria-label="of all days: ${parts.map((p) => `${pct(p.w)} ${p.label.toLowerCase()}`).join(", ")}">` +
    parts.map((p) => `<span class="cmp-seg ${p.cls}" style="width:${p.w}%" title="${p.label}: ${pct(p.w)}"></span>`).join("") +
  `</div>`;
  // Key under the bar: a color swatch + name + share for every non-empty bucket, in
  // the same cold→hot order as the bar, so small spans are still clearly labeled. On
  // desktop each label wants its segment's midpoint (recorded in data-mid); spreadKeys
  // then de-collides them after layout. On mobile it falls back to a plain wrapping row.
  let acc = 0;
  const key = `<div class="cmp-key">` +
    parts.map((p) => {
      const mid = acc + p.w / 2; acc += p.w;
      return `<span class="cmp-key-item" data-mid="${mid}" style="left:${mid}%"><i class="cmp-sw ${p.cls}"></i>${p.label} <b>${pct(p.w)}</b></span>`;
    }).join("") +
  `</div>`;
  const bands = `<div class="cmp-bands"><b>${pct(s.tolPct)}</b> within tolerance · typically <b>${deg(s.mad)}</b> off comfort</div>`;
  const sub = win ? `<span class="cmp-best">★ Best match</span> · ${s.n} days` : `${s.n} days`;
  return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
    `<div class="cmp-card-top">` +
      `<span class="cmp-rank">${rank}</span>` +
      `<div class="cmp-card-name">${nameHierarchy(loc.name)}<span class="cmp-daycount">${sub}</span></div>` +
      `<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` +
    `</div>` +
    bar + key + bands +
  `</div>`;
}

// A placeholder card shown while a location's data is still streaming in, so the
// results don't jump when it resolves. Animated by CSS (.cmp-skel).
const skeletonRow = () => `<div class="cmp-card cmp-skel" aria-hidden="true">` +
    `<div class="cmp-skel-top"><span class="cmp-skel-badge"></span>` +
    `<span class="cmp-skel-name"></span><span class="cmp-skel-big"></span></div>` +
    `<div class="cmp-skel-bar"></div></div>`;

// Baseline strip: every place's typical basis temperature on one shared °F axis,
// with the comfort range shaded, so the places' baselines line up for direct
// comparison. Each row shows the average (dot) and the middle-80% spread (bar),
// colored by where the average sits relative to the comfort range.
function renderBaseline(scored) {
  if (!scored.length) { baseline.hidden = true; baseline.innerHTML = ""; return; }
  let min = Math.min(lo, tlo), max = Math.max(hi, thi);
  for (const { s } of scored) { min = Math.min(min, s.p10); max = Math.max(max, s.p90); }
  const pad = Math.max(3, (max - min) * 0.06);   // keep markers off the edges
  min -= pad; max += pad;
  const span = max - min || 1;
  const posF = (v) => clamp((v - min) / span * 100, 0, 100);
  const bandL = posF(lo), bandR = posF(hi), tolL = posF(tlo), tolR = posF(thi);
  const posClass = (t) => (t < lo ? "cmp-below" : t > hi ? "cmp-above" : "cmp-comfort");
  const rows = scored.map(({ loc, s }) => {
    const cls = posClass(s.avgTemp), a = posF(s.avgTemp);
    return `<div class="cmp-bl-row">` +
      `<span class="cmp-bl-name" title="${loc.name}">${namePrimary(loc.name)}</span>` +
      `<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` +
        `<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` +
        `<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` +
        `<span class="cmp-bl-spread ${cls}" style="left:${posF(s.p10)}%;right:${100 - posF(s.p90)}%"></span>` +
        `<span class="cmp-bl-dot ${cls}" style="left:${a}%"></span>` +
        `<span class="cmp-bl-val" style="left:${a}%">${fmtTemp(s.avgTemp)}</span>` +
      `</div></div>`;
  }).join("");
  baseline.hidden = false;
  baseline.innerHTML =
    `<div class="cmp-bl-head">Typical temperature by place ` +
      `<span class="hint">dot = average · bar = middle-80% of days · shaded = comfort, faint = tolerance</span></div>` +
    `<div class="cmp-bl-rows">${rows}</div>` +
    `<div class="cmp-bl-axis"><span>${fmtTemp(min)}</span><span>${fmtTemp((min + max) / 2)}</span><span>${fmtTemp(max)}</span></div>`;
}

function renderResults() {
  const loading = locations.filter((l) => l.loading).length;
  const scored = locations
    .map((l) => ({ loc: l, s: l.series ? computeStats(visibleSeries(l)) : null }))
    .filter((x) => x.s);
  // Best match score first; break ties by the smaller typical miss.
  scored.sort((a, b) => (b.s.score - a.s.score) || (a.s.mad - b.s.mad));

  renderBaseline(scored);

  if (!scored.length) {
    head.hidden = true;
    // Loaded places can score empty when the month filter excludes all their days.
    const filteredOut = !loading && monthsActive() && locations.some((l) => l.series && l.series.length);
    results.innerHTML = loading
      ? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` +
        Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("")
      : filteredOut
        ? `<p class="muted">No days fall in the selected months. Widen the time-of-year filter.</p>`
        : "";
    return;
  }

  // Headline: the winner + the parameters it was judged under.
  const win = scored[0];
  const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
  const winName = namePrimary(win.loc.name);
  const lead = scored.length > 1
    ? `<b>${winName}</b> is the best match for ${fmtTemp(lo)}–${fmtTemp(hi)}: score ${Math.round(win.s.score)}/100`
    : `<b>${winName}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}–${fmtTemp(hi)}`;
  head.hidden = false;
  head.innerHTML = `<h2>${lead}</h2>` +
    `<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}–${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}–${fmtTemp(thi)} · ${span}` +
      `${loading ? ` · <span class="cmp-loading-inline"><span class="cmp-spinner"></span>loading ${loading} more…</span>` : ""}</p>`;

  results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("") +
    (loading ? Array.from({ length: loading }, skeletonRow).join("") : "");
  spreadKeys();
}

// The desktop key wants each label centred on its segment's midpoint, but small or
// adjacent buckets make labels overlap. After layout, de-collide each row with the
// classic 1-D label placement: keep every label at its ideal midpoint, and only where
// labels would touch, merge them into a block positioned to minimise the total shift
// from their ideals — so an isolated label never moves, and a cluster centres on its
// members rather than sliding to one edge. Finally clamp the run inside the bar.
// Mobile uses a plain wrapping row, so there we just clear any positions we applied.
function spreadKeys() {
  const desktop = window.matchMedia("(min-width: 641px)").matches;
  const GAP = 10;
  results.querySelectorAll(".cmp-key").forEach((keyEl) => {
    const items = [...keyEl.children];
    if (!desktop) {
      items.forEach((el) => { el.style.left = el.style.transform = ""; });
      return;
    }
    if (!items.length) return;
    const W = keyEl.clientWidth;
    // Each label's ideal left edge = its segment midpoint minus half its width.
    const labs = items.map((el) => {
      const w = el.offsetWidth;
      return { el, w, ideal: (parseFloat(el.dataset.mid) / 100) * W - w / 2 };
    });
    // Sweep left→right, growing a block whenever the next label would overlap the
    // current one. A block records Σ(idealₖ − offsetₖ) and its count so its left edge
    // = that mean, the least-squares spot for its members.
    const blocks = [];
    for (const it of labs) {
      let b = { items: [it], left: it.ideal, width: it.w, sum: it.ideal, n: 1 };
      blocks.push(b);
      while (blocks.length >= 2) {
        const a = blocks[blocks.length - 2], cur = blocks[blocks.length - 1];
        if (a.left + a.width + GAP <= cur.left) break;       // no overlap → settled
        const offset = a.width + GAP;                        // cur's items shift this far into a
        a.sum += cur.sum - cur.n * offset;
        a.n += cur.n;
        a.width += GAP + cur.width;
        a.items = a.items.concat(cur.items);
        a.left = a.sum / a.n;
        blocks.pop();
      }
    }
    // Clamp the run inside the bar: off the left wall forward, off the right wall back.
    let minLeft = 0;
    for (const b of blocks) { if (b.left < minLeft) b.left = minLeft; minLeft = b.left + b.width + GAP; }
    let maxRight = W;
    for (let i = blocks.length - 1; i >= 0; i--) {
      const b = blocks[i];
      if (b.left + b.width > maxRight) b.left = maxRight - b.width;
      maxRight = b.left - GAP;
    }
    // Lay each block's labels out left→right from the block's left edge.
    for (const b of blocks) {
      let x = b.left;
      for (const it of b.items) {
        it.el.style.left = `${x + it.w / 2}px`;
        it.el.style.transform = "translateX(-50%)";
        x += it.w + GAP;
      }
    }
  });
}

// ---- distribution (independent of the comfort filter) ----
// One calendar-style Record-Low→Record-High connected bar chart per loaded location
// for the selected metric; the share/count toggle flips every figure. Category names
// are percentile tiers, but each bar now shows the tier's average value + range, so it
// re-renders on the °C/°F toggle (wired below).
function renderDist() {
  const withData = locations
    .map((l) => ({ l, vis: visibleSeries(l) }))
    .filter((x) => x.vis && x.vis.length);
  if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
  distBody.innerHTML = withData.map(({ l, vis }) => {
    const buckets = metricBuckets(vis, distMetric);
    const total = buckets.reduce((n, b) => n + b.n, 0);
    const name = namePrimary(l.name) || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
    const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
    return `<div class="cmp-dist-row">` +
      `<div class="cmp-dist-name" title="${l.name || ""}">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
      strip + `</div>`;
  }).join("");
  distSection.hidden = false;
}

function renderAll() {
  const has = locations.length > 0;
  params.hidden = !has;
  comfortWrap.hidden = !has;
  placeholder.hidden = has;
  addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
  for (const b of refreshBtns) b.hidden = !isDirty();
  renderLocList();
  renderResults();
  renderDist();
}

// ---- events ----
initFindButton(addBtn, "Add location",
  () => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
  (lat, lon) => addLocation(lat, lon));

for (const b of refreshBtns) b.addEventListener("click", refresh);

locList.addEventListener("click", (e) => {
  const x = e.target.closest(".cmp-chip-x");
  if (x) removeLocation(+x.dataset.i);
});

// ---- comfort range slicer ----
// Two range inputs overlaid on a shared track; keep lo ≤ hi and mirror the selection
// in the label + fill. State stays in °F; the range re-ranks instantly (no refetch).
const pctPos = (v) => (v - R_MIN) / (R_MAX - R_MIN) * 100;
function syncSlicer() {
  tloInput.value = tlo; loInput.value = lo; hiInput.value = hi; thiInput.value = thi;
  comfortValEl.textContent = `${fmtTemp(lo)}–${fmtTemp(hi)}`;
  tolValEl.textContent = `${fmtTemp(tlo)}–${fmtTemp(thi)}`;
  fillEl.style.left = `${pctPos(lo)}%`;
  fillEl.style.right = `${100 - pctPos(hi)}%`;
  fillTolEl.style.left = `${pctPos(tlo)}%`;
  fillTolEl.style.right = `${100 - pctPos(thi)}%`;
  // A temperature marker above each handle (the four band borders).
  for (const [k, v] of [["tlo", tlo], ["lo", lo], ["hi", hi], ["thi", thi]]) {
    tickEls[k].style.left = `${pctPos(v)}%`;
    tickEls[k].textContent = fmtTemp(v);
  }
}
function slicerChanged() {
  lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
  lsSet(CMP.tlo, String(tlo)); lsSet(CMP.thi, String(thi));
  syncSlicer();
  writeHashState();
  renderResults();   // the distribution is comfort-independent — no need to touch it
}
// Comfort thumbs stay inside the tolerance thumbs; tolerance thumbs stay outside
// comfort — so the four handles can never cross (tlo ≤ lo ≤ hi ≤ thi).
loInput.addEventListener("input", () => { lo = clamp(+loInput.value, tlo, hi); slicerChanged(); });
hiInput.addEventListener("input", () => { hi = clamp(+hiInput.value, lo, thi); slicerChanged(); });
tloInput.addEventListener("input", () => { tlo = Math.min(+tloInput.value, lo); slicerChanged(); });
thiInput.addEventListener("input", () => { thi = Math.max(+thiInput.value, hi); slicerChanged(); });

// The °F/°C toggle only flips what's shown — the range and series stay in °F (the
// API's unit) — so re-render the range label, the cards, and the distribution chart
// (its per-category averages + ranges are in the active unit).
onUnitChange(() => { syncSlicer(); renderResults(); renderDist(); });

// Label widths (and the desktop/mobile threshold) move with the viewport, so
// re-spread the rank-card keys after the layout settles.
let spreadRaf = 0;
window.addEventListener("resize", () => {
  cancelAnimationFrame(spreadRaf);
  spreadRaf = requestAnimationFrame(spreadKeys);
});

// Distribution metric: an inline selector (by the chart) plus a synced copy in the
// filter sheet. Either one drives the shared distMetric; both reflect the active state.
const syncDistButtons = () =>
  document.querySelectorAll("#cmp-dist-metric button, #cmp-dist-metric-sheet button")
    .forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
function setDistMetric(m) {
  distMetric = m;
  lsSet(CMP.distMetric, distMetric);
  syncDistButtons();
  renderDist();
}
for (const t of [distToggle, distToggleSheet]) {
  t.addEventListener("click", (e) => {
    const btn = e.target.closest("button[data-metric]");
    if (btn) setDistMetric(btn.dataset.metric);
  });
}
distSection.addEventListener("change", (e) => {
  if (!e.target.closest("#cmp-dist-count")) return;
  distCount = e.target.checked;
  lsSet(CMP.distCount, distCount ? "1" : "0");
  renderDist();
});

// Basis: instant re-rank over data already in hand — no refetch.
basisToggle.addEventListener("click", (e) => {
  const btn = e.target.closest("button[data-basis]");
  if (!btn) return;
  basis = btn.dataset.basis;
  lsSet(CMP.basis, basis);
  basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
  writeHashState();
  renderResults();
});

// ---- time-of-year (season / month) filter ----
// Seasons are primary, months the nested suboption; both drive the same Set. A toggle
// re-ranks and re-buckets over data already in hand — no refetch. The dropdown isn't
// rebuilt on change (that would collapse it): the native checkboxes hold their own
// state, and we only re-derive the season indeterminate state + the summary text.
function buildSeasonFilter() {
  seasonDd.innerHTML = seasonFilterDropdown(checkedMonths);
  syncSeasonChecks(seasonDd, checkedMonths);
}
initSeasonExpand(seasonDd);
seasonDd.addEventListener("change", (e) => {
  const cb = e.target.closest("input[type=checkbox]");
  if (!cb || !applySeasonMonthChange(cb, checkedMonths)) return;
  syncSeasonChecks(seasonDd, checkedMonths);
  seasonDd.querySelector(".cal-dd-sum").textContent = seasonSummaryText(checkedMonths);
  lsSet(CMP.months, monthsToMask(checkedMonths));
  writeHashState();
  renderResults();
  renderDist();
  if (fabSheet) fabSheet.setActive(monthsActive());
});

// Mobile: a floating pill slides the whole params panel up as a bottom sheet.
let fabSheet = null;

// Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll).
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
startInput.addEventListener("change", renderAll);
endInput.addEventListener("change", renderAll);
clickOpensPicker(startInput, endInput);   // whole-field tap opens the month picker

// ---- init ----
(function restore() {
  const hash = readHashState();
  if (hash) {
    if (hash.lo != null) lo = hash.lo;
    if (hash.hi != null) hi = hash.hi;
    if (hash.tlo != null) tlo = hash.tlo;
    if (hash.thi != null) thi = hash.thi;
    if (hash.basis) basis = hash.basis;
    if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
    if (hash.months) checkedMonths = hash.months;
  }
  // Keep comfort ordered and inside tolerance even if a hand-edited link isn't.
  if (lo > hi) { const t = lo; lo = hi; hi = t; }
  tlo = Math.min(tlo, lo); thi = Math.max(thi, hi);

  syncSlicer();
  basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
  syncDistButtons();   // active state on both the inline + sheet distribution selectors
  document.getElementById("cmp-dist-count").checked = distCount;
  startInput.value = range.start; endInput.value = range.end;
  buildSeasonFilter();
  fabSheet = initFilterSheet({ panel: params, label: "Filters" });
  fabSheet.setActive(monthsActive());

  // A shared link's locations win; else the last-used set; else seed from the spot
  // the other views were on so the page isn't empty. All three auto-load on arrival
  // (the tap-to-refresh rule is for later interactive edits, not the initial view).
  let list = hash && hash.locs;
  if (!list) { try { const saved = JSON.parse(lsGet(CMP.locs)); if (Array.isArray(saved)) list = saved; } catch (e) {} }
  if (!list || !list.length) { const last = loadLastLocation(); list = last ? [{ lat: last.lat, lon: last.lon }] : []; }

  locations = list
    .filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number")
    .map((l) => ({ lat: l.lat, lon: l.lon, name: l.name || null, series: null, loading: false, error: null }));

  writeHashState();
  const token = ++loadToken;
  loadPending(token);
  renderAll();
})();
