// ------------------------------------------------------------------------
//  compute.js - Build the per-hour display rows from the raw API data.
//
//  Pure-ish function: feed in (forecast, airQuality, location, vehicleType,
//  vehicleVent, vehicleSpeed, buildingType, furColor) and get back
//  { hourlyRows, days, utcOffsetMs }.
//
//  Open-Meteo with timezone=auto returns local wall-clock strings like
//  "2026-05-13T14:00" - no Z suffix. Two forms are used in each row:
//    - String slices (iso.slice(...)) for display & day grouping
//    - A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*).
// ------------------------------------------------------------------------

import {
  vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
  calcConcreteTempPass, calcVehicleInteriorTempPass,
  calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
  calcFurSurfaceTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';

export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
  const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
  const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;

  // Build a fast lookup map from the air quality hourly data: ISO string - index.
  // Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
  // "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
  const aqTimeMap = {};
  if (airQuality?.hourly?.time) {
    airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; });
  }
  const getAq = (field, iso) => {
    if (!airQuality?.hourly?.[field]) return null;
    const i = aqTimeMap[iso.slice(0, 13)];
    if (i === undefined) return null;
    return airQuality.hourly[field][i] ?? null;
  };

  const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
    const h = forecast.hourly;
    const Ta   = h.temperature_2m[i];
    const RH   = h.relative_humidity_2m[i];
    const dew  = h.dew_point_2m ? h.dew_point_2m[i] : null;
    const va   = h.wind_speed_10m[i];
    const wd   = h.wind_direction_10m ? h.wind_direction_10m[i] : null;
    const gust = h.wind_gusts_10m    ? h.wind_gusts_10m[i]    : null;
    const dir  = h.direct_radiation[i]    || 0;
    const dif  = h.diffuse_radiation[i]   || 0;
    const glob = h.shortwave_radiation[i] || 0;
    const cc     = h.cloud_cover[i];
    const ccLow  = h.cloud_cover_low  ? h.cloud_cover_low[i]  : null;
    const ccMid  = h.cloud_cover_mid  ? h.cloud_cover_mid[i]  : null;
    const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null;
    const uv     = h.uv_index ? (h.uv_index[i] || 0) : 0;
    const precip      = h.precipitation[i] || 0;
    const precipProb  = h.precipitation_probability ? (h.precipitation_probability[i] ?? 0) : 0;
    const lightning   = h.lightning_potential ? (h.lightning_potential[i] ?? 0) : 0;
    const cape        = h.cape                ? (h.cape[i]                ?? 0) : 0;
    const snow        = h.snowfall[i] || 0;
    const soilT0 = h.soil_temperature_0cm    ? h.soil_temperature_0cm[i]    : null;
    const soilT6 = h.soil_temperature_6cm    ? h.soil_temperature_6cm[i]    : null;
    const soilM  = h.soil_moisture_0_to_1cm  ? h.soil_moisture_0_to_1cm[i]  : null;
    // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
    // For display we slice the string directly - no Date object needed.
    // For solarElevationDeg (which uses .getUTC* internally) we need the
    // true UTC instant: treat the local time as UTC then subtract the offset.
    // e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 -
    // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
    // For solarElevationDeg (which uses .getUTC* internally) we need the
    // true UTC instant: treat the local time as UTC then subtract the offset.
    const dt   = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
    const elev = solarElevationDeg(location.lat, location.lon, dt);
    // Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete.
    // direct_radiation is zero on fully overcast days - far more accurate than
    // shortwave_radiation which can be unreliably high even at 100% cloud cover.
    // Diffuse (scattered light through cloud) contributes ~20% as much heat to
    // a surface as direct beam, so we weight it accordingly.
    const effectiveRad = dir + dif * 0.2;
    // Apply environment modifier - adjust solar inputs and air temp for shaded environments.
    const TaEnv   = Ta   + env.taOffset;
    const dirEnv  = dir  * env.dirFactor;
    const difEnv  = dif  * env.difFactor;
    const globEnv = glob * env.globFactor;
    // concreteT is now stamped in the two-pass section below (thermal lag).
    // vehicleT is stamped in the two-pass section below (thermal lag).
    const eh       = vaporPressureHpa(Ta, RH);
    // UTCI: the standard, unmodified benchmark. Computed from the raw
    // open-ground observation only - NO Solar Model anywhere in it. Its whole
    // job is to be the fixed reference SunSoak is read against, so it must
    // not move when the user switches environment; if both numbers shifted
    // together the comparison would say nothing. Only the SunSoak family
    // (utciAdj, Shade, Tmrt) responds to the Solar Model.
    const TmrtStd  = calcTmrt(Ta, dir, dif, glob, elev);
    const utci     = utciApprox(Ta, TmrtStd, va, eh);
    // SunSoak: same peer-reviewed polynomial, every input shifted by the
    // active Solar Model (air temp via taOffset, radiation via dir/dif/glob
    // factors), then the rain/snow soak penalty on top.
    const Tmrt     = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
    const utciEnv  = utciApprox(TaEnv, Tmrt, va, eh);
    const utciAdj  = utciEnv + precipPenalty(precip, snow, va);
    // Shade air temp: what a thermometer in this Solar Model's typical shade
    // (building shadow, beach umbrella, canopy...) would read. Shares
    // SunSoak's TaEnv baseline and env-reduced radiation, so the two stay
    // consistent. Pet Shade reads this directly; the human Shade column
    // below turns it into a felt temperature first.
    const shadeAirT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
    // Shade (felt): SunSoak with the direct beam taken away — still outdoors,
    // so the same wind, humidity and rain penalty apply. See
    // calcShadeFeltTemp() for why this is a felt number and not the air temp.
    const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, va, eh, elev, env.shade)
                 + precipPenalty(precip, snow, va);
    // Derived
    const compass = windCompass8(wd);
    const { uvA, uvB } = uvSplit(uv, elev);
    const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
    // Visibility from the main forecast API (metres - km).
    const visKm        = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
    const aqi          = getAq('european_aqi', iso);
    const grassPollen  = getAq('grass_pollen', iso);
    const birchPollen  = getAq('birch_pollen', iso);
    const alderPollen  = getAq('alder_pollen', iso);
    const mugwortPollen= getAq('mugwort_pollen', iso);
    const olivePollen  = getAq('olive_pollen', iso);
    const ragweedPollen= getAq('ragweed_pollen', iso);
    // -- FUTURE FEATURE: Activity "What If" Modifier ---------------------------
    // Add two extra columns driven by a user-selected activity level. These are
    // intentionally kept SEPARATE from the core columns above so that baseline
    // profile data stays consistent and comparable across profiles.
    //
    // The user picks an activity from a simple UI picker (no live data needed -
    // this is a forecast/planning tool, not a tracker):
    //   Resting - Walking - Cycling - Running - Sport/Intense
    //
    // Two output columns only (keep it clean):
    //
    //   adjustedSafeTime  - baseline UV safe exposure time - an activity multiplier.
    //                       Higher activity = shorter safe time, because:
    //                         - metabolic heat raises core body temp
    //                         - sweating washes away sunscreen faster
    //                         - more skin blood flow = higher UV sensitivity
    //                       Suggested multipliers (tune with real data):
    //                         Resting: 1.0 (no change)
    //                         Walking: 0.85
    //                         Cycling: 0.75  *This will need a airflow calc as going at speed is cooling
    //                         Running: 0.60  *This will need a airflow calc as going at speed is cooling
    //                         Sport:   0.50  *This will need a airflow calc as going at speed is cooling
	//
	//	*Danny Notes: These faster speed activities might need intergrating into the future "Vehicle Speed" calcs somehow?
    //
    //   heatStressLevel   - a simple label: 'Low' | 'Moderate' | 'High' | 'Very High'
    //                       Derived from UTCI + activity heat load. A runner at
    //                       UTCI 28-C should read 'High' even if a resting person
    //                       would read 'Moderate' at the same UTCI.
    //                       Colour code in the UI: - - - -
    //
    // Implementation sketch:
    //   1. Accept 'activityLevel' as a new param to buildHourlyRows() alongside
    //      vehicleType, buildingType etc.
    //   2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level).
    //   3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier
    //   4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels.
    //   5. Add both fields to the returned row object below.
    //   6. In components.js, render these as optional columns that only appear when
    //      an activity other than 'Resting' is selected - keeps the default table clean.
    // -----------------------------------------------------------------------------

    return {
      iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
      cc, ccLow, ccMid, ccHigh, cloudCat,
      uv, uvA, uvB,
      precip, precipProb, lightning, cape, snow,
      soilT0, soilT6, soilM, shadeT,
      // Pet Shade stays on the shade AIR temperature: the human Shade column
      // is a UTCI felt number on a human comfort scale, which says nothing
      // useful about a cat. It also aggregates as a MEAN rather than a worst
      // case, so it needs its own field either way (same for pawT/concreteT).
      petShadeT: shadeAirT,
      effectiveRad,
      elev, Tmrt, utci, utciAdj, eh, compass,
      visKm, aqi,
      grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
    };
  }) : [];

  // Two-pass calculations: concrete thermal lag + indoor + vehicle cabin.
  // All need the full hourly arrays so they can look back at previous
  // hours. Run after hourlyRows is built, then stamp each row.
  if (hourlyRows.length > 0) {
    const TaArr       = hourlyRows.map(r => r.Ta);
    const globArr     = hourlyRows.map(r => r.glob);
    const elevArr     = hourlyRows.map(r => r.elev);
    const radArr      = hourlyRows.map(r => r.effectiveRad);
    const vaArr       = hourlyRows.map(r => r.va);
    const uvArr       = hourlyRows.map(r => r.uv);
    const cloudCatArr = hourlyRows.map(r => r.cloudCat);
    const soilMArr    = hourlyRows.map(r => r.soilM);
    const precipArr   = hourlyRows.map(r => r.precip);
    const snowArr     = hourlyRows.map(r => r.snow);

    // Concrete surface temperature with thermal lag (1.5 h time constant).
    // A slab baking in the sun retains heat when cloud rolls in, and takes
    // a couple of hours of sunshine to fully heat up from a cold start.
    const concreteTemps = calcConcreteTempPass(
      TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
    );
    hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });

    const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
    const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
    hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });

    // Vehicle cabin temperature with thermal lag. A parked vehicle climbs
    // toward the hour's equilibrium rather than jumping to it, so a van that
    // has been in the sun since morning reads hotter than the same van an
    // hour after parking. vaArr feeds ambient wind into shell convection.
    const vehicleTemps = calcVehicleInteriorTempPass(
      TaArr, globArr, elevArr, vaArr, vehicleType, vehicleVent, vehicleSpeed
    );
    hourlyRows.forEach((r, i) => { r.vehicleT = vehicleTemps[i]; });

    // Fur surface temperature with thermal lag - Pets profile.
    const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
    const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo, uvArr, cloudCatArr);
    hourlyRows.forEach((r, i) => { r.furSurfaceT = furTemps[i]; });
  }

  // Group those hourly rows into days for the day tabs.
  const days = [];
  hourlyRows.forEach(row => {
    const key = row.iso.slice(0, 10);
    let day = days.find(d => d.key === key);
    if (!day) { day = { key, rows: [] }; days.push(day); }
    day.rows.push(row);
  });

  // -- FUTURE FEATURE v2: Today Summary + Alert System --------------------------
  // After grouping rows into days, generate a per-day summary object that powers
  // a stylish "Today at a Glance" panel shown above or below the main dial.
  //
  // The summary is NOT a live alert/push system - it's a forecast digest that
  // refreshes with the forecast data. Think of it as a smart briefing card.
  //
  // WHAT TO COMPUTE (per day, from that day's rows):
  //   - Peak UTCI+P and time it occurs        - heat stress headline
  //   - Min UTCI+P and time                   - cold stress headline
  //   - Peak UV index and time                - UV warning
  //   - Max precipitation rate and time       - rain/ice warning
  //   - Max vehicle cabin temp                - "dangerous to leave pets/children in car"
  //   - Max pollen level + type               - pollen advisory
  //   - Road condition risk (low air temp + precip - ice risk)
  //
  // ALERT CATEGORIES (each generates a styled warning card if threshold exceeded):
  //   --  Heat warning      UTCI+P > 32-C
  //   -  Cold warning       UTCI+P < 0-C
  //   --  UV warning         UV index > 6
  //   --  Heavy rain         precip > 4mm/h
  //   -  Ice/road risk      Ta < 3-C + any precip (or recent precip overnight)
  //   -  Vehicle danger     vehicleT > 35-C ("don't leave pets or children in car")
  //   -  High pollen        any pollen type > 50 grains/m-
  //
  // DESIGN NOTES:
  //   - Cards should be concise - one line of bold text + a short explanation
  //   - Colour-coded to match the existing UTCI stress band palette
  //   - Collapsible - show top 2-3 alerts by default, expand for full list
  //   - For today only (days[0]); optionally extend to day tabs in a later pass
  //   - The "X-C above seasonal norm" historical context line (see app.js comment)
  //     could live here too, as a subtle subheading under the dial temperature
  //
  // Suggested return shape - add to the return value below:
  //   daySummaries: days.map(day => buildDaySummary(day.rows))
  //
  // where buildDaySummary() is a new helper in this file (or a separate
  // summary.js module if it grows large).
  // -----------------------------------------------------------------------------

  return { hourlyRows, days, utcOffsetMs };
}

// ------------------------------------------------------------------------
//  aggregateRows(rows, interval) - Compress a day's hourly rows into
//  multi-hour buckets for the table view (1h / 2h / 3h / 4h).
//
//  Buckets are CLOCK-ALIGNED from midnight, so a 2h view groups 00-01,
//  02-03, ...; 3h groups 00-02, 03-05, ...; etc. Each output row keeps the
//  LANDING (first) hour's positional fields - iso, dt, solar elevation,
//  global radiation, wind direction, sky/cloud category - so the little
//  scope, clock label and wind vane all show the time the column lands on.
//
//  Every other column is aggregated with the rule that fits its meaning:
//    - SUM  : precipitation totals (1mm/h x 3h = 3mm)
//    - MAX  : risk/peak columns (rain %, UV, gusts, managed indoor)
//    - MEAN : smooth continuous quantities (RH, wind, cloud, Tmrt, ...)
//    - FELT : worst-case temperature - the warmest hour if the block reaches
//             >=20C (heat is the concern), otherwise the coldest hour (cold
//             is the concern). Applies to EVERY temperature column the user
//             judges comfort or risk by - SunSoak, Cabin, Indoors, Concrete,
//             Soil, Shade and Air - so they never disagree with each other
//             at 2h/3h/4h. Note Air is therefore a block worst case, not a
//             block average, and will read hotter than a mean on a warm day.
//
//  Derived render values (Delta, Burn) are recomputed downstream from the
//  aggregated Air / UTCI / UV, so they stay consistent automatically.
//
//  interval <= 1 returns the input untouched (zero behaviour change).
// ------------------------------------------------------------------------
const AGG_MEAN = [
  'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
  'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
  // Thermal columns that read as a plain block average rather than a worst
  // case: Tmrt and Dew above, plus the pet trio. pawT/petShadeT mirror
  // concreteT/shadeT hourly but must NOT inherit their FELT aggregation.
  'furSurfaceT', 'pawT', 'petShadeT',
  'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
];
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust'];
const AGG_SUM  = ['precip', 'snow'];
// Every temperature the user reads as "how hot/cold does this actually get"
// shares one rule, so a 3h row never disagrees with the columns beside it.
const AGG_FELT = [
  'utci', 'utciAdj',                      // SunSoak
  'vehicleT', 'concreteT',                // Cabin, Concrete
  'indoorT', 'managedT',                  // Indoors + Managed Indoors (+ Pet Home)
  'soilT0', 'soilT6', 'shadeT', 'Ta',     // Soil surface + root, Shade, Air
];

// Arithmetic mean of an array, or null when empty. Shared by aggregateRows
// and computeCropAdvice.
const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;

export function aggregateRows(rows, interval) {
  if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;

  // Numbers for a field across the group, skipping null / NaN.
  const nums = (group, f) => group.map(r => r[f]).filter(v => v != null && !isNaN(v));
  const maxV = (vals) => vals.length ? Math.max(...vals) : null;
  const felt = (vals) => {
    if (!vals.length) return null;
    const hi = Math.max(...vals);
    return hi >= 20 ? hi : Math.min(...vals);
  };

  // Split rows into clock-aligned buckets by floor(localHour / interval).
  const buckets = [];
  let cur = null, curKey = null;
  for (const r of rows) {
    const hour = parseInt(r.iso.slice(11, 13), 10);
    const key  = Math.floor(hour / interval);
    if (key !== curKey) { cur = []; buckets.push(cur); curKey = key; }
    cur.push(r);
  }

  return buckets.map(group => {
    // Start from the landing row so positional fields (iso, dt, elev, glob,
    // wd, compass, cloudCat) carry through unchanged, then overwrite the
    // aggregatable columns.
    const out = { ...group[0] };
    AGG_MEAN.forEach(f => { out[f] = mean(nums(group, f)); });
    AGG_MAX.forEach(f  => { out[f] = maxV(nums(group, f)); });
    AGG_SUM.forEach(f  => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); });
    AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); });
    // Bucket span markers used by the table for "now" highlighting and for
    // matching event cell-tags that fall on any hour within the bucket.
    out.isoHours = group.map(r => r.iso.slice(0, 13));
    out.isoEnd   = group[group.length - 1].iso;
    return out;
  });
}

// ------------------------------------------------------------------------
//  computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its
//  contributing factors for the "Why it feels like this" panel.
//
//  Returns contributions in degrees C relative to plain air temperature.
//  Positive = warmer than air temp, negative = cooler.
//
//  Attribution method - sequential isolation using utciApprox:
//    sunAndSky     - Tmrt vs Ta. Mean radiant temp captures net heat from
//                    direct sun, sky scatter, and ground reflection combined.
//    wind          - UTCI with wind vs without (Tmrt = Ta, neutral RH 50%).
//                    Almost always negative - wind cools.
//    humidity      - UTCI at actual vapour pressure vs neutral RH 50%.
//                    Positive when muggy, near-zero when dry.
//    environment   - taOffset from the active UTCI environment modifier.
//                    e.g. urban +2.5, forest -2.0, desert +3.5.
//    precipitation - precipPenalty() - always 0 or negative.
// ------------------------------------------------------------------------
export function computeWhyFeelsLike(row, env) {
  if (!row) return null;
  const { Ta, Tmrt, va, eh, precip, snow } = row;
  const r1 = (v) => Math.round(v * 10) / 10;

  // Environment-adjusted air temp (same as used in buildHourlyRows).
  const TaEnv = Ta + (env ? env.taOffset : 0);

  // Sequential UTCI isolation — all deltas are in the same UTCI-polynomial
  // space so they add up: Ta + environment + sunAndSky + wind + humidity
  // + precipitation ≈ SunSoak (utciAdj), within rounding.
  //
  // Tmrt already has env radiation scaling (dirFactor/difFactor/globFactor)
  // baked in from buildHourlyRows, so each delta automatically reflects
  // the active Solar Model without any extra work here.
  const ehNeutral = vaporPressureHpa(TaEnv, 50);
  const base      = utciApprox(TaEnv, TaEnv, 0, ehNeutral); // ≈ TaEnv

  // Radiation: effect of actual Tmrt vs no-radiation baseline (wind=0, RH=50%).
  const sunAndSky = r1(utciApprox(TaEnv, Tmrt, 0, ehNeutral) - base);

  // Wind: cooling effect of actual wind with solar present (RH still neutral).
  const wind      = r1(utciApprox(TaEnv, Tmrt, va, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));

  // Humidity: actual vapour pressure vs neutral RH 50%, with solar and wind.
  const humidity  = r1(utciApprox(TaEnv, Tmrt, va, eh) - utciApprox(TaEnv, Tmrt, va, ehNeutral));

  // Environment modifier - explicit temperature offset from the env config.
  const environment = env ? r1(env.taOffset) : 0;

  // Precipitation soak penalty - negative or zero.
  const precipitation = precipPenalty(precip, snow, va);

  return { base: r1(base), sunAndSky, wind, humidity, environment, precipitation };
}

// ------------------------------------------------------------------------
//  computeGlanceSummary(todayRows, profile, variant, skinType) - Build the
//  environment-aware "Today at a Glance" items for the current day.
//
//  Returns an array of { icon, label, value, alert } objects (4-5 items).
//  Content varies by profile and sub-variant so it stays relevant to
//  whatever the user is actually doing.
//
//  Parameters:
//    todayRows  - hourlyRows for the selected day
//    profile    - active profile key e.g. "farming", "vehicle", "home"
//    variant    - active sub-variant key e.g. "running", "beach" (or null)
//    skinType   - Fitzpatrick skin type key for UV burn time
// ------------------------------------------------------------------------
export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null, normals = null, dayKey = null) {
  if (!todayRows || todayRows.length === 0) return [];
  const show = (key) => !cols || !!cols[key];

  const hhmm = (iso) => {
    if (!iso) return null;
    const h = parseInt(iso.slice(11, 13), 10);
    const m = iso.slice(14, 16);
    const period = h < 12 ? 'am' : 'pm';
    const h12 = h % 12 || 12;
    return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
  };
  const hhmmEnd = (iso) => {
    if (!iso) return null;
    const h = parseInt(iso.slice(11, 13), 10) + 1;
    const m = iso.slice(14, 16);
    const period = (h % 24) < 12 ? 'am' : 'pm';
    const h12 = h % 12 || 12;
    return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
  };
  const dayRows = todayRows.filter(r => r.elev > 0);

  const peakRow = (field) => todayRows.reduce((best, r) =>
    (r[field] != null && (best == null || r[field] > best[field])) ? r : best, null);

  const peakRainRow = todayRows.reduce((best, r) =>
    (r.precipProb != null && (best == null || r.precipProb > best.precipProb)) ? r : best, null);
  const maxRainProb = peakRainRow ? (peakRainRow.precipProb ?? 0) : 0;

  // All contiguous runs of rows that satisfy a predicate. Each run is
  // { start, end, len }. Used so an "at a glance" window can report more than
  // one range - e.g. good driving in the morning AND again in the evening.
  const allWindows = (rows, cond) => {
    const out = [];
    let cur = null;
    for (const r of rows) {
      if (cond(r)) {
        cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 };
      } else if (cur) {
        out.push(cur); cur = null;
      }
    }
    if (cur) out.push(cur);
    return out;
  };

  // Format the most significant windows as "h – h", one range per line, in
  // chronological order. Caps at `max` ranges (longest kept) so a split day
  // reads cleanly. Returns null when there are no windows. The newline is
  // preserved by `white-space: pre-line` on .insight-value.
  const formatWindows = (wins, max = 2) => {
    if (!wins || wins.length === 0) return null;
    return [...wins]
      .sort((a, b) => b.len - a.len)
      .slice(0, max)
      .sort((a, b) => (a.start.iso < b.start.iso ? -1 : 1))
      .map(w => `${hhmm(w.start.iso)} – ${hhmmEnd(w.end.iso)}`)
      .join('\n');
  };

  const pollenLabel = (v) => {
    if (v == null || v < 0) return null;
    if (v < 10)  return 'Low';
    if (v < 50)  return 'Moderate';
    if (v < 200) return 'High';
    return 'Very High';
  };

  const aqiLabel = (v) => {
    if (v == null) return null;
    if (v < 20) return 'Good';
    if (v < 40) return 'Fair';
    if (v < 60) return 'Moderate';
    return 'Poor';
  };

  const lightningLabel = (v) => {
    if (v <= 0)  return null;
    if (v < 5)   return 'Low';
    if (v < 25)  return 'Moderate';
    return 'High';
  };
  const peakLightningRow = todayRows.reduce((best, r) =>
    (r.lightning ?? 0) > (best?.lightning ?? 0) ? r : best, null);
  const peakLightning = peakLightningRow?.lightning ?? 0;
  const lightningRisk = lightningLabel(peakLightning);
  const lightningItem = lightningRisk ? [{
    icon: '⚡',
    label: 'Lightning risk',
    value: `${lightningRisk} at ${hhmm(peakLightningRow.iso)}`,
    alert: peakLightning >= 5,
    grp: 'precip',
  }] : [];

  const moistureLabel = (v) => {
    if (v == null) return null;
    if (v < 0.15) return 'Dry';
    if (v < 0.30) return 'Slightly dry';
    if (v < 0.45) return 'Moist';
    return 'Saturated';
  };

  const rainItem = {
    icon: '🌧',
    label: 'Rain risk',
    value: maxRainProb > 0 && peakRainRow?.iso
      ? `${Math.round(maxRainProb)}% at ${hhmm(peakRainRow.iso)}`
      : `${Math.round(maxRainProb)}%`,
    alert: maxRainProb >= 60,
  };

  // ── Vs seasonal norms (climate anomalies) ──────────────────────────────
  // One row comparing this day against the 1991-2020 norm for the date. The
  // headline is the standard climate anomaly — the day's 24h AVERAGE vs the
  // normal mean — with the day's HIGH vs the normal high carried alongside as
  // the peak people actually notice. Kept in a single row because two rows
  // sharing the icon and "1991-2020 … warmer" phrasing read as duplicates
  // giving contradictory answers. An honest climate cue.
  const climateItem = (() => {
    if (!normals) return [];
    const key = dayKey || todayRows[0]?.iso?.slice(0, 10);
    if (!key) return [];
    const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
    const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1);
    const taVals = todayRows.map(r => r.Ta).filter(v => v != null);
    if (taVals.length === 0) return [];
    const dayHigh = Math.max(...taVals);
    const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length;
    // Either day-of-year table can have null slots — reduceNormals keeps the
    // { high, mean } shape as long as one series survived — so treat each as
    // independently optional.
    const nHigh = normals.high?.[doy];
    const nMean = normals.mean?.[doy];
    const aHigh = nHigh != null ? dayHigh - nHigh : null;
    const aMean = nMean != null ? dayMean - nMean : null;
    // Show the row when EITHER anomaly is notable. The mean anomaly is usually
    // the smaller of the two (a clear night pulls it back towards normal), so
    // gating on it alone would hide days with a dramatic peak.
    const notable = v => v != null && Math.abs(v) >= 3;
    if (!notable(aHigh) && !notable(aMean)) return [];
    const fmt = a => `${a >= 0 ? '+' : '−'}${Math.abs(a).toFixed(1)}°`;
    const word = a => (a >= 0 ? 'warmer' : 'cooler');
    // Each half keeps its own sign and word, so a day whose peak and mean fall
    // on opposite sides of the norm still reads correctly.
    // `sub` is the secondary figure — rendered inline on mobile, on its own
    // line in the narrow desktop rail (see .insight-sub in ui.css).
    let value, sub = null;
    if (aMean != null && aHigh != null) {
      value = `${fmt(aMean)} ${word(aMean)}`;
      sub = `(${fmt(aHigh)} peak)`;
    } else if (aMean != null) {
      value = `${fmt(aMean)} ${word(aMean)}`;
    } else {
      value = `${fmt(aHigh)} peak ${word(aHigh)}`;
    }
    // Flagged off the high, matching the "5 degrees against the normal high" rule.
    const alert = (aHigh != null ? aHigh : aMean) >= 5;
    return [{ icon: '🌡', label: 'Pre-2020 24h Average', value, sub, alert, grp: 'ambient' }];
  })();

  // ── Good Driving Time ──────────────────────────────────────────────────
  // Shown whenever a road speed (not Static) is selected and the vehicle cabin
  // column is active - so it appears in both the Vehicle and Driver profiles.
  // Longest run of hours, across the full day since lorries run day and night,
  // that are NOT poor driving conditions: a hot cabin (>29 °C), heavy rain,
  // snow/ice, fog/thick mist, or gale-force wind.
  const GALE_MS = 39 / 2.237; // 39 mph gust = Gale (Force 8)
  const drivingShown = show('vehicleT') && vehicleSpeed !== 'static';
  const drivingWins = drivingShown
    ? allWindows(todayRows, r => {
        const gustMs = r.gust ?? r.va;
        return (r.vehicleT == null || r.vehicleT <= 29) && // cabin not dangerously hot
               (r.precip   == null || r.precip   < 4)    && // not heavy rain (mm/h)
               (r.snow     == null || r.snow     === 0)  && // no snow
               (r.Ta       == null || r.Ta       > 1)    && // no ice risk
               (r.visKm    == null || r.visKm    >= 4)    && // not fog / thick mist
               (gustMs     == null || gustMs     < GALE_MS); // not gale-force wind
      })
    : [];
  const drivingItem = {
    icon: '🚚',
    label: 'Good driving time',
    value: formatWindows(drivingWins) ?? 'Drive with care',
    alert: drivingWins.length === 0,
    grp: 'felt',
  };

  // ── Farming ────────────────────────────────────────────────────────────
  if (profile === 'farming') {
    const fieldWins = allWindows(dayRows, r =>
      r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12
    );
    const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10);
    const soilM       = todayRows.find(r => r.soilM != null)?.soilM ?? null;
    const maxPollen   = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));

    return [
      ...climateItem,
      {
        icon: '⏱',
        label: 'Best field work',
        value: formatWindows(fieldWins) ?? 'No suitable window',
        alert: fieldWins.length === 0,
        grp: 'surface',
      },
      ...(show('soilT') ? [{
        icon: '🌱',
        label: 'Soil warms to 10°C by',
        value: soilWarmRow ? (hhmm(soilWarmRow.iso) === '00:00' ? 'All Day' : hhmm(soilWarmRow.iso)) : 'Not today',
        alert: !soilWarmRow,
        grp: 'surface',
      }] : []),
      ...(show('precipProb') ? [rainItem] : []),
      ...lightningItem,
      ...(show('soilM') ? [{
        icon: '💧',
        label: 'Soil moisture',
        value: moistureLabel(soilM) ?? '-',
        alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
        grp: 'surface',
      }] : []),
      ...(weekDays ? computeCropAdvice(weekDays, lat) : []),
      ...(show('pollen') && maxPollen >= 10 ? [{
        icon: '🌿',
        label: 'Grass pollen',
        value: pollenLabel(maxPollen),
        alert: maxPollen >= 50,
        grp: 'airqual',
      }] : []),
    ];
  }

  // ── Vehicle ────────────────────────────────────────────────────────────
  if (profile === 'vehicle') {
    const peakCabin  = peakRow('vehicleT');
    const dangerWins = allWindows(todayRows, r => r.vehicleT != null && r.vehicleT >= 29);

    return [
      ...climateItem,
      ...(show('vehicleT') ? [{
        icon: '🌡',
        label: 'Peak cabin temp',
        value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-',
        alert: !!(peakCabin && peakCabin.vehicleT > 31.5),
        grp: 'felt',
      }, {
        icon: '🧒',
        label: 'Children/pets in car',
        value: dangerWins.length ? `Unsafe ${formatWindows(dangerWins)}` : 'Safe all day',
        alert: dangerWins.length > 0,
        grp: 'felt',
      }] : []),
      ...(show('precipProb') ? [rainItem] : []),
      ...lightningItem,
      ...(drivingShown ? [drivingItem] : []),
      {
        icon: '🌤',
        label: 'Best travel comfort',
        value: (() => {
          const best = todayRows.reduce((b, r) =>
            (b == null || r.utciAdj < b.utciAdj) ? r : b, null);
          return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-';
        })(),
        alert: false,
        grp: 'felt',
      },
    ];
  }

  // ── Home ───────────────────────────────────────────────────────────────
  if (profile === 'home') {
    const peakIndoor  = peakRow('indoorT');
    const peakManaged = peakRow('managedT');
    const ventWins  = allWindows(todayRows, r =>
      r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20
    );
    const peakAqi   = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
    const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));

    return [
      ...climateItem,
      ...(show('indoorT') ? [{
        icon: '🌡',
        label: 'Peak indoor (unmanaged)',
        value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
        alert: !!(peakIndoor && peakIndoor.indoorT >= 28),
        grp: 'felt',
      }] : []),
      ...(show('managedT') ? [{
        icon: '🌡',
        label: 'Peak indoor (managed)',
        value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-',
        alert: !!(peakManaged && peakManaged.managedT >= 28),
        grp: 'felt',
      }] : []),
      ...(show('indoorT') ? [{
        icon: '🪟',
        label: 'Open windows',
        value: formatWindows(ventWins) ?? 'Keep closed',
        alert: false,
        grp: 'felt',
      }] : []),
      ...lightningItem,
      ...(show('aqi') && peakAqi > 0 ? [{
        icon: '💨',
        label: 'Air quality',
        value: aqiLabel(peakAqi),
        alert: peakAqi >= 60,
      }] : []),
      ...(show('pollen') && maxPollen >= 10 ? [{
        icon: '🌼',
        label: 'Pollen',
        value: pollenLabel(maxPollen),
        alert: maxPollen >= 50,
        grp: 'airqual',
      }] : []),
    ];
  }

  // ── Pets (cats / small dogs) ────────────────────────────────────────────
  if (profile === 'pets') {
    const peakFur    = peakRow('furSurfaceT');
    const peakShade  = peakRow('petShadeT');
    const peakIndoor = peakRow('indoorT');
    const maxPollen  = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
    const peakAqi    = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
    const carDangerWins = allWindows(todayRows, r => r.vehicleT != null && r.vehicleT >= 29);

    return [
      ...(show('furSurfaceT') ? [{
        icon: '🐾',
        label: 'Sunsoak for pet',
        value: peakFur ? `${Math.round(peakFur.furSurfaceT)}° at ${hhmm(peakFur.iso)}` : '-',
        alert: !!(peakFur && peakFur.furSurfaceT >= 45),
        grp: 'surface',
      }] : []),
      ...(show('petShadeT') ? [{
        icon: '🌳',
        label: 'Shade for pet',
        value: peakShade ? `${Math.round(peakShade.petShadeT)}° at ${hhmm(peakShade.iso)}` : '-',
        alert: !!(peakShade && peakShade.petShadeT >= 26),
        grp: 'ambient',
      }] : []),
      ...(show('petHomeT') ? [{
        icon: '🏠',
        label: 'Pet at home',
        value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
        alert: !!(peakIndoor && peakIndoor.indoorT >= 26),
        grp: 'ambient',
      }] : []),
      ...(show('vehicleT') ? [{
        icon: '🧒',
        label: 'Kids/pets in car',
        value: carDangerWins.length ? `Unsafe ${formatWindows(carDangerWins)}` : 'Safe all day',
        alert: carDangerWins.length > 0,
        grp: 'felt',
      }] : []),
      ...lightningItem,
      ...(show('aqi') && peakAqi > 0 ? [{
        icon: '💨',
        label: 'Air quality',
        value: aqiLabel(peakAqi),
        alert: peakAqi >= 60,
        grp: 'airqual',
      }] : []),
      ...(show('pollen') && maxPollen >= 10 ? [{
        icon: '🌼',
        label: 'Pollen',
        value: pollenLabel(maxPollen),
        alert: maxPollen >= 50,
        grp: 'airqual',
      }] : []),
    ];
  }

  // ── Activities - running / cycling ─────────────────────────────────────
  if (variant === 'running' || variant === 'cycling') {
    const coolWins = allWindows(dayRows, r =>
      r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30
    );
    const peakUvRow  = peakRow('uv');
    const peakAqi    = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
    const maxPollen  = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
    const burnMins   = peakUvRow && peakUvRow.uv > 0
      ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
      : null;

    return [
      ...climateItem,
      {
        icon: '⏱',
        label: `Best ${variant} window`,
        value: formatWindows(coolWins) ?? 'No cool window today',
        alert: coolWins.length === 0,
        grp: 'felt',
      },
      ...(show('precipProb') ? [rainItem] : []),
      ...lightningItem,
      ...(show('burn') && burnMins ? [{
        icon: '☀',
        label: 'UV burn time (peak)',
        value: burnMins,
        alert: !!(peakUvRow && peakUvRow.uv >= 6),
        grp: 'solar',
      }] : []),
      ...(show('aqi') && peakAqi > 0 ? [{
        icon: '💨',
        label: 'Air quality',
        value: aqiLabel(peakAqi),
        alert: peakAqi >= 60,
      }] : []),
      ...(show('pollen') && maxPollen >= 10 ? [{
        icon: '🌿',
        label: 'Pollen',
        value: pollenLabel(maxPollen),
        alert: maxPollen >= 50,
      }] : []),
    ];
  }

  // ── Outdoors - beach, park, events etc. (and fallback) ─────────────────
  const comfortWins = allWindows(dayRows, r =>
    r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30
  );
  const peakFelt   = peakRow('utciAdj');
  const peakUvRow  = peakRow('uv');
  const peakAqi    = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
  const maxPollen  = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
  const burnMins   = peakUvRow && peakUvRow.uv > 0
    ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
    : null;

  return [
    ...climateItem,
    ...(drivingShown ? [drivingItem] : []),
    {
      icon: '🌤',
      label: 'Comfortable window',
      value: formatWindows(comfortWins) ?? 'No comfortable window',
      alert: comfortWins.length === 0,
      grp: 'felt',
    },
    {
      icon: '🌡',
      label: 'Peak felt temp',
      value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-',
      alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)),
      grp: 'felt',
    },
    ...((show('burn') || show('uvA')) && burnMins ? [{
      icon: '☀',
      label: 'UV burn time (peak)',
      value: burnMins,
      alert: !!(peakUvRow && peakUvRow.uv >= 6),
      grp: 'solar',
    }] : []),
    ...(show('precipProb') ? [rainItem] : []),
    ...lightningItem,
    ...(show('aqi') && peakAqi > 0 ? [{
      icon: '💨',
      label: 'Air quality',
      value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
        grp: 'airqual',
      }] : []),
      ...(show('pollen') && maxPollen >= 10 ? [{
      icon: '🌿',
      label: 'Pollen',
      value: pollenLabel(maxPollen),
      alert: maxPollen >= 50,
      grp: 'airqual',
    }] : []),
  ];
}

// ------------------------------------------------------------------------
//  computeCropAdvice(weekDays, lat) - Seasonal "Good to sow / harvest"
//  advice for the Farming "At a glance" panel.
//
//  Blends the current calendar month (which crops are in their sow/harvest
//  window) with the upcoming week's weather (is the seedbed warm and
//  workable, is there a dry spell to bring grain in?). Returns 0-2
//  glance-style { icon, label, value, alert } items, appended after the
//  standard farming insights.
//
//  Each verdict is one of three tiers - clearly good (crops listed plain),
//  borderline (crops listed with a "(marginal)" suffix) or hold. The middle
//  tier exists so a technically-passing but shaky call doesn't read with the
//  same confidence as an ideal one.
//
//  Sowing looks at the DAILY MEAN soil temperature at 6 cm - drilling depth -
//  rather than a surface peak, because bare soil at 0 cm swings 15 °C+ over a
//  day and a sunny afternoon says nothing about the seedbed. It wants that
//  threshold held for a run of consecutive days AND the soil trending warmer
//  ("at temperature and rising"), plus workable moisture, no air frost ahead
//  for tender crops, and no downpour due straight after drilling.
//
//  Harvest wants a genuinely CONSECUTIVE dry run for grain/rape/onions, and
//  gates root crops on soil moisture - lifting spuds off saturated ground
//  means ruts, compaction and damaged tubers.
//
//  Accuracy caveats worth knowing before trusting a verdict:
//    - Soil fields come from ICON Global at ~11 km (see buildSoilUrl in
//      hooks/useForecast.js) - a regional average, not this field.
//    - The 0.45 m³/m³ wetness threshold is texture-agnostic: near saturation
//      on sand, around field capacity on clay. A proper fix needs a soil
//      texture lookup.
//    - soil_moisture_0_to_1cm is the skin layer and dries within hours of
//      rain, so it overstates workability after a shower.
//      soil_moisture_3_to_9cm would be the better input if this is revisited.
//
//  Parameters:
//    weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
//    lat      - forecast latitude; < 0 flips the UK calendar by +6 months
// ------------------------------------------------------------------------

// Soil moisture bands shared by the sow and harvest gates (m³/m³). WET matches
// the "Saturated" cut in moistureLabel() so the two glance rows agree.
const SOIL_WET        = 0.45;
const SOIL_BORDERLINE = 0.40;

// LONGEST run of consecutive entries satisfying `pred`, as { start, len }.
// Deliberately the longest and not the first: one unsettled day early in the
// week must not hide a good four-day spell behind it.
function bestRun(arr, pred) {
  let best = { start: -1, len: 0 }, start = -1, len = 0;
  for (let i = 0; i < arr.length; i++) {
    if (pred(arr[i])) {
      if (start < 0) start = i;
      len++;
      if (len > best.len) best = { start, len };
    } else {
      start = -1; len = 0;
    }
  }
  return best;
}

// ------------------------------------------------------------------------
//  computeBestDay(weekDays, nowLocalISO) - every day in the
//  next week with a decent unbroken run of pleasant outdoor hours, for
//  "The Week Ahead".
//
//  The app already works out comfort windows for the selected day, but
//  comparing days meant tapping through all fourteen tabs. This answers the
//  question the day tabs make you do by hand.
//
//  Naming a single winner was too thin to plan around: knowing Saturday is
//  the best day says nothing about whether Sunday is also fine, or whether
//  the whole week is a washout. So it lists the days that qualify, in date
//  order, each with a 0-100 score so they can be compared at a glance
//  without reordering them out of the order you plan in.
//
//  Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
//  one selected day, and a week-scoped line reads as a category error inside
//  it. It renders in its own panel below the rail instead.
//
//  Scored on utciAdj (SunSoak) rather than the profile's own main field.
//  Every row has it whatever profile is active, and it is the number that
//  actually answers "would I enjoy being outside" - unlike vehicleT or
//  indoorT, where a "best day" framing would be meaningless.
//
//  Daylight hours only, and hours already past today are skipped, so a warm
//  morning that has been and gone can't win.
// ------------------------------------------------------------------------

const PICK_MIN_HOURS = 3;   // shorter than this isn't worth naming a day for
const PICK_MAX_DAYS  = 4;   // beyond this the shortlist stops being a shortlist

// Said when no day qualifies, phrased for what was actually being judged -
// "no settled outdoor windows" is meaningless when the panel was reading a
// car cabin.
const EMPTY_VALUE = {
  utciAdj:     'no settled outdoor windows',
  vehicleT:    'no comfortable cabin windows',
  indoorT:     'no comfortable indoor windows',
  furSurfaceT: 'no safe windows for a pet outside',
};

// ── The four bands ───────────────────────────────────────────────────────
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that
// measure, at the limit it scores zero, and past the limit the hour is not a
// day-out hour at all. That split is what lets the panel say "Thursday is
// fine, Saturday is better" instead of only ever answering yes or no.
//
// SunSoak is the exception: rather than one ideal point it has a flat top
// spanning the Comfortable and Warm thermal-stress bands - anywhere in there
// is a good day out and splitting hairs between 20 and 25 would be false
// precision - then falls 100% to 0% across the Cool band below and the
// Caution band above. Past those it isn't a day-out hour at all.
//
// Read off UTCI_BANDS rather than written as numbers, so the panel and the
// table's colour bands can never drift apart: today that is a 19-27 plateau
// ramping out to 15 and 32.
const bandMax = (bands, label) => bands.find(b => b.label === label).max;
const plateau = (bands) => ({
  min:     bandMax(bands, 'Chilly'),      // bottom of Cool  - score 0
  flatLo:  bandMax(bands, 'Cool'),        // bottom of Comfortable - score 1
  flatHi:  bandMax(bands, 'Warm'),        // top of Warm     - score 1
  max:     bandMax(bands, 'Caution'),     // top of Caution  - score 0
});
const SUNSOAK = plateau(UTCI_BANDS);
// Pets are judged on fur-surface temperature, which is coloured off PET_BANDS
// in the table - reading the plateau off the same array keeps the panel and
// the cell colours in step there too.
const PET_PLATEAU = plateau(PET_BANDS);
const BAND_GUST    = { ideal: 15, limit: 25 };   // mph
const BAND_RAIN    = { ideal: 0,  limit: 25 };   // % chance
const BAND_SOIL    = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy

// 1 at the ideal end, 0 at the limit, clamped. Direction is taken from the
// two numbers, so it reads the same whether lower or higher is better.
function bandScore(v, { ideal, limit }) {
  if (v == null) return null;                 // unknown - caller decides
  const f = (limit - v) / (limit - ideal);
  return Math.max(0, Math.min(1, f));
}

function plateauScore(v, pl) {
  if (v == null) return null;
  if (v >= pl.flatLo && v <= pl.flatHi) return 1;
  const f = v < pl.flatLo
    ? (v - pl.min) / (pl.flatLo - pl.min)
    : (pl.max - v) / (pl.max - pl.flatHi);
  return Math.max(0, Math.min(1, f));
}

// ── What "a good window" means for the active profile ────────────────────
// The panel used to answer one question - "would I enjoy standing outside"
// - whatever profile was selected, which made it useless to the very users
// who had told the app what they were doing. It now scores the SAME field the
// rest of the app is built around for that profile (the day tabs, the dial,
// the main table column), so "the week ahead" answers the question the
// profile implies: when to drive, when the house is bearable, when to walk
// the dog.
//
//   thermal - weight on the profile's main temperature field
//   rain / gust / soil - the outdoor nuisances, only where they can matter
//
// Driving keeps rain and gust (both are what makes a journey unpleasant or
// unsafe) and drops soil, which says nothing about a road. Indoors drops all
// three: rain on the roof does not change whether the bedroom is sleepable.
// Daylight-only is likewise a solar concern - a cool cabin at 6am is a real
// answer to "when should I drive", so vehicle and indoor windows run around
// the clock.
function bestDayCriteria(profile, variant) {
  const { mainField } = deriveProfileMain(profile, variant);
  switch (mainField) {
    case 'vehicleT':
      return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false,
               w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } };
    case 'indoorT':
      return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false,
               w: { thermal: 1, rain: 0, gust: 0, soil: 0 } };
    case 'furSurfaceT':
      return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true,
               w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } };
    default:
      return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true,
               w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } };
  }
}

function comfortableHour(r, crit) {
  if (crit.daylightOnly && !(r.elev > 0)) return false;
  const t = r[crit.field];
  if (t == null) return false;
  if (t < crit.pl.min || t > crit.pl.max) return false;
  // A measure that carries no weight for this profile can't exclude an hour
  // either, or an indoor window would still be cut short by a shower.
  if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) return false;
  if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
  // Soil comes from a separate feed and is null wherever that fetch fails or
  // the model has no coverage - treat missing as passing, or a soil outage
  // would silently empty the panel everywhere. Only the wet end excludes: dust
  // -dry ground is a poor day out for the garden, not for the person on it.
  if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
  return true;
}

// How good an hour is WITHIN the bands, 0-1. Weighted by how much each
// measure actually decides whether the day was worth going out for.
function hourQuality(r, crit) {
  const parts = [
    [plateauScore(r[crit.field], crit.pl),                crit.w.thermal],
    [bandScore(r.precipProb ?? 0, BAND_RAIN),             crit.w.rain],
    [bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust],
    [bandScore(r.soilM, BAND_SOIL),                       crit.w.soil],
  ].filter(([v, w]) => v != null && w > 0);
  const wsum = parts.reduce((s, [, w]) => s + w, 0);
  return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
}

export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
  if (!weekDays || weekDays.length === 0) return [];
  const crit = bestDayCriteria(profile, variant);

  const candidates = [];
  for (const day of weekDays.slice(0, 7)) {
    if (!day?.rows?.length) continue;
    // Drop hours that have already passed - only ever bites on day 0.
    const rows = nowLocalISO
      ? day.rows.filter(r => r.iso.slice(0, 13) >= nowLocalISO)
      : day.rows;

    let start = -1, len = 0, dayBest = null;
    for (let i = 0; i < rows.length; i++) {
      if (comfortableHour(rows[i], crit)) {
        if (start < 0) start = i;
        len++;
        if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
      } else {
        start = -1; len = 0;
      }
    }
    if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;

    // Average quality across the run. Length alone can't separate days: in a
    // temperate summer week half of them qualify dawn to dusk, and picking
    // arbitrarily among those is a coin toss.
    const run     = rows.slice(dayBest.start, dayBest.end + 1);
    const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;

    candidates.push({
      len: dayBest.len, quality, key: day.key,
      from: rows[dayBest.start], to: rows[dayBest.end],
      // What the window is measured AGAINST: the daylight for a profile that
      // only counts daylight hours, otherwise the whole day. Using daylight
      // for a round-the-clock profile would let a 14-hour overnight window
      // score over 100% of a 16-hour day and print "all day" for a spell that
      // ends at breakfast.
      usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length,
    });
  }
  // Nothing qualifying is itself worth saying - an empty panel reads as a bug,
  // and "don't bother this week" is a real answer to the question being asked.
  if (candidates.length === 0) {
    return [{
      icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
      label: 'Next 7 days',
      value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj,
      alert: false,
    }];
  }

  // Score each day out of 100 against an ideal day out rather than against
  // the rest of the week: a middling week should read as middling, not have
  // its least-bad day flattered up to full marks.
  //
  //   how much of the daylight is usable            (50%)
  //   where in the bands those hours actually sit   (50%)
  //
  // Even between the two: a long window of merely tolerable hours and a short
  // window of perfect ones are both worth knowing about, and letting either
  // half dominate hides one of them.
  for (const c of candidates) {
    const span = c.usable > 0 ? Math.min(c.len / c.usable, 1) : 0;
    c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
  }

  // Only the top few are kept: past four rows the panel stops being a
  // shortlist and turns back into the day tabs.
  const shown = [...candidates]
    .sort((a, b) => b.score - a.score)
    .slice(0, PICK_MAX_DAYS)
    .sort((a, b) => (a.key < b.key ? -1 : 1));

  const hh = (iso) => {
    const h = parseInt(iso.slice(11, 13), 10);
    return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`;
  };

  return shown.map(c => {
    // Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
    // as UTC to stop the browser's own zone shifting the weekday.
    const dayName = new Date(`${c.key}T00:00:00Z`)
      .toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });

    // The end row is the last comfortable hour, so the window runs to the end
    // of it - 9am-12pm means 9:00 up to 12:59.
    const endIso = `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;

    // A run spanning nearly all the daylight is better said than shown: printing
    // "6am - 9pm" makes the reader parse a time range to learn "all day".
    const window = c.usable > 0 && c.len >= c.usable - 1
      ? 'all day'
      : `${hh(c.from.iso)} – ${hh(endIso)}`;

    return {
      icon: '📅',
      label: dayName,
      value: window,
      // Rows stay in date order - the order you plan in - so the ranking is
      // carried by the score bar instead of by position.
      score: c.score,
      alert: false,
      // No grp: the group tints categorise the glance rail's mixed subjects,
      // but here every row is the same kind of thing, so a colour would be
      // decoration. Plain rows with hairline dividers instead.
    };
  });
}

// Header line for the panel: names what is being judged when a variant makes
// that non-obvious, otherwise the plain framing.
export function bestDaysLabel(profile, variant) {
  const { mainField, mainLabel } = deriveProfileMain(profile, variant);
  // The vehicle and home profiles have no variant to name, but "best times
  // out" would be plainly wrong for them - the panel is now reading the cabin
  // and the room, not the pavement.
  if (profile === 'vehicle') return 'Best times to drive';
  if (profile === 'home')    return 'Best times indoors';
  if (profile === 'pets')    return 'Best times for pets';
  return profile === 'outdoors' && variant
    ? `Best times for ${mainLabel}`
    : (mainField === 'utciAdj' ? 'Best times out' : `Best times for ${mainLabel}`);
}

// Tooltip on the score bar. It has to name the field being scored, or a
// driver reads "temperatures nearer 20 °C" against the outside air and
// concludes the panel is broken.
export function bestDaysHint(profile, variant, score) {
  const { mainField } = deriveProfileMain(profile, variant);
  const what = {
    vehicleT:    'an ideal driving day — longer stretches with a comfortable cabin score higher',
    indoorT:     'an ideal day indoors — longer stretches with the building in comfortable range score higher',
    furSurfaceT: 'an ideal day for a pet outside — longer stretches with fur and ground temperatures in safe range score higher',
  }[mainField] ?? 'an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher';
  return `${score}% of ${what}`;
}

export function computeCropAdvice(weekDays, lat) {
  if (!weekDays || weekDays.length === 0) return [];
  const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);
  if (days.length === 0) return [];

  // "Now" - the month of the first available day (1-12).
  const month = parseInt((days[0].key || '').slice(5, 7), 10);
  if (!month) return [];

  // Southern hemisphere: shift the stored UK months by +6 before testing.
  const south = lat != null && lat < 0;
  const inSeason = (months) => {
    const shifted = south ? months.map(m => ((m + 5) % 12) + 1) : months;
    return shifted.includes(month);
  };

  // ── Per-day aggregates ───────────────────────────────────────────────────
  // One pass; everything below reads from these rather than re-scanning rows.
  const daily = days.map(d => {
    const at6 = d.rows.map(r => r.soilT6).filter(v => v != null);
    const at0 = d.rows.map(r => r.soilT0).filter(v => v != null);
    const dt  = new Date((d.key || '') + 'T00:00Z');
    return {
      // 6 cm is drilling depth; fall back to the 0 cm skin only if a model
      // swap drops the 6 cm field, so the row still says something.
      soilMeanT: at6.length ? mean(at6) : (at0.length ? mean(at0) : null),
      soilMMean: mean(d.rows.map(r => r.soilM).filter(v => v != null)),
      rainTotal: d.rows.reduce((s, r) => s + (r.precip ?? 0), 0),
      maxProb:   Math.max(0, ...d.rows.map(r => r.precipProb ?? 0)),
      minTa:     Math.min(Infinity, ...d.rows.map(r => r.Ta ?? Infinity)),
      name:      dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }),
    };
  });

  // ── Seedbed signals ──────────────────────────────────────────────────────
  // Longest consecutive run of days whose mean seedbed temp clears `minT`.
  const runAt = (minT) => bestRun(daily, d => d.soilMeanT != null && d.soilMeanT >= minT).len;

  // "At temperature AND rising": end of the week warmer than the start.
  const early = mean(daily.slice(0, 3).map(d => d.soilMeanT).filter(v => v != null));
  const late  = mean(daily.slice(4, 7).map(d => d.soilMeanT).filter(v => v != null));
  const rising = early != null && late != null && (late - early) >= 0.3;

  // Best margin over a threshold across the week - a soil sitting well above
  // the floor is a safer call than one scraping it.
  const bestMean = Math.max(-Infinity, ...daily.map(d => d.soilMeanT ?? -Infinity));
  const soilKnown = bestMean > -Infinity;
  const marginOver = (minT) => (soilKnown ? bestMean - minT : null);

  // Workability now, not seven days out: mean over the next 48 h only.
  const nearMoist   = mean(daily.slice(0, 2).map(d => d.soilMMean).filter(v => v != null));
  const groundWet   = nearMoist != null && nearMoist >= SOIL_WET;
  const groundDamp  = nearMoist != null && nearMoist >= SOIL_BORDERLINE && nearMoist < SOIL_WET;

  // Air frost anywhere in the window rules out tender crops.
  const frostAhead = daily.some(d => d.minTa < 0);

  // A downpour onto a fresh seedbed caps it; downgrade, never block.
  const postSowSoak = daily.slice(0, 3).reduce((s, d) => s + d.rainTotal, 0) > 25;

  // ── Shared row plumbing ──────────────────────────────────────────────────
  const listCrops = (crops) => crops.map(c => c.label).join(', ');

  // Split a season list into ready / marginal by a per-crop classifier.
  const classify = (crops, fn) => {
    const ready = [], marginal = [];
    for (const c of crops) {
      const v = fn(c);
      if (v === 'ready') ready.push(c);
      else if (v === 'marginal') marginal.push(c);
    }
    return { ready, marginal };
  };

  // Ready crops list plain; otherwise marginal crops carry the suffix. Keeping
  // them separate means a plain list always means "genuinely good to go".
  const buildRow = (icon, label, { ready, marginal }, readyValue, holdValue) => (
    ready.length ? {
      icon, label, value: readyValue(ready), alert: false, grp: 'surface',
    } : marginal.length ? {
      icon, label, value: `${listCrops(marginal)} (marginal)`, alert: false, grp: 'surface',
    } : {
      icon, label, value: holdValue, alert: true, grp: 'surface',
    }
  );

  const out = [];

  // ── Good to sow ──────────────────────────────────────────────────────────
  const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months));
  if (sowSeason.length) {
    const sowState = classify(sowSeason, (c) => {
      // Under glass: the outdoor seedbed simply doesn't apply.
      if (c.sow.underCover) return 'ready';
      // No soil reading at all is "unknown", not "fine" - don't guess.
      if (!soilKnown) return 'no';
      const run = runAt(c.sow.minSoilT);
      if (groundWet || run === 0) return 'no';
      if (c.sow.tender && frostAhead) return 'no';
      const margin = marginOver(c.sow.minSoilT);
      const solid  = run >= 3 && (rising || (margin != null && margin >= 2));
      return (solid && !groundDamp && !postSowSoak) ? 'ready' : 'marginal';
    });

    // Under-cover crops pass on the calendar alone, so on their own they must
    // NOT read as a green light for the field - qualify them instead.
    const onlyUnderCover = sowState.ready.length > 0
      && sowState.ready.every(c => c.sow.underCover);
    const sowReadyValue = (ready) => onlyUnderCover
      ? `${listCrops(ready)} (under cover)`
      : listCrops(ready);

    // Hold reason by precedence: unknown beats wet beats cold beats frost.
    const holdValue = !soilKnown ? 'Soil data unavailable'
      : groundWet ? 'Hold off — ground too wet'
      : sowSeason.some(c => runAt(c.sow.minSoilT) === 0) ? 'Hold off — soil still cold'
      : 'Hold off — frost forecast';

    out.push(buildRow('🌱', 'Good to sow', sowState, sowReadyValue, holdValue));
  }

  // ── Good to harvest ──────────────────────────────────────────────────────
  const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months));
  if (harvestSeason.length) {
    // A dry SPELL, not scattered dry days: under 2 mm and rain chance < 40%.
    const isDry = (d) => d.rainTotal < 2 && d.maxProb < 40;
    const spell = bestRun(daily, isDry);
    const dryRange = () => {
      const names = daily.slice(spell.start, spell.start + spell.len).map(d => d.name);
      return names.length === 1
        ? `dry ${names[0]}`
        : `dry ${names[0]}–${names[names.length - 1]}`;
    };

    const harvestState = classify(harvestSeason, (c) => {
      // Grain, rape and onions must come in / cure dry.
      if (c.harvest.dry) return spell.len >= 3 ? 'ready' : spell.len === 2 ? 'marginal' : 'no';
      // Root crops are lifted by machine - saturated ground means ruts.
      if (c.harvest.lift) return groundWet ? 'no' : groundDamp ? 'marginal' : 'ready';
      // Hand-cut (lettuce): month window is enough.
      return 'ready';
    });

    const dryDriven = spell.len > 0 && harvestState.ready.some(c => c.harvest.dry);
    const readyValue = (ready) => dryDriven
      ? `${listCrops(ready)} (${dryRange()})`
      : listCrops(ready);

    // Hold reason: only blame the ground when every stuck crop is a root crop.
    // (A hold means nothing was ready or marginal, so the whole season is stuck.)
    const holdValue = harvestSeason.every(c => c.harvest.lift)
      ? 'Hold — ground too wet to lift'
      : 'Hold — too wet to harvest grain';

    out.push(buildRow('🌾', 'Good to harvest', harvestState, readyValue, holdValue));
  }

  return out;
}