var _useState = React.useState;
var _useEffect = React.useEffect;
var _useRef = React.useRef;
var _useMemo = React.useMemo;
var _useCallback = React.useCallback;

/**
 * WeatherPage — the destination behind the dashboard weather panel.
 * Route: /weather
 *
 * Why this page exists at all: the dashboard panel answers "what is it doing outside".
 * This page answers the question the app actually cares about — "is the weather setting
 * my home up for mould". Humidity, and how long it has stayed high, is the variable that
 * drives growth, so it gets the largest single block on the page (a trend chart plus a
 * plain-English read). Temperature is context, not the headline.
 *
 * ADR-0007 posture: this is decision support. The risk read describes conditions and what
 * they mean for damp. It makes no health claim and never diagnoses.
 *
 * Visual reference: the Skycard demo in the platform repo
 * (~/Projects/Roz-Weather-API/demo/skycard) — its animated sky, primitive-built SVG icon
 * system and card composition. Nothing is imported from it; this app has no build step or
 * module system, and the palette here is Mould Detect's, not Skycard's.
 *
 * DATA CONTRACT. Every field read from /api/weather lives in wxPageMap() below and
 * nowhere else. If the endpoint's shape differs from what is guessed here, that one
 * function is the only thing that needs to change.
 */

// ---------------------------------------------------------------------------
// Data mapping — THE ONLY PLACE THAT TOUCHES API FIELD NAMES
// ---------------------------------------------------------------------------

/** Finite-number coercion. Returns null for anything unusable. */
var num = function (v) {
    if (v === null || v === undefined || v === '') return null;
    var n = typeof v === 'number' ? v : parseFloat(v);
    return isFinite(n) ? n : null;
};

/** First finite number found by scanning each source object for each key, in order. */
var pickNum = function (sources, keys) {
    for (var s = 0; s < sources.length; s++) {
        var src = sources[s];
        if (!src || typeof src !== 'object') continue;
        for (var k = 0; k < keys.length; k++) {
            var n = num(src[keys[k]]);
            if (n !== null) return n;
        }
    }
    return null;
};

/** First non-empty value (any type) found by scanning sources for each key, in order. */
var pickVal = function (sources, keys) {
    for (var s = 0; s < sources.length; s++) {
        var src = sources[s];
        if (!src || typeof src !== 'object') continue;
        for (var k = 0; k < keys.length; k++) {
            var v = src[keys[k]];
            if (v !== null && v !== undefined && v !== '') return v;
        }
    }
    return null;
};

/** Tolerant date parse: 'YYYY-MM-DD', ISO datetime, epoch seconds or millis. */
var toDate = function (v) {
    if (v === null || v === undefined || v === '') return null;
    if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
    if (typeof v === 'number') {
        var ms = v < 1e11 ? v * 1000 : v;
        var dn = new Date(ms);
        return isNaN(dn.getTime()) ? null : dn;
    }
    var str = String(v);
    // Bare calendar dates are read at local midday so a timezone shift never moves the day.
    if (/^\d{4}-\d{2}-\d{2}$/.test(str)) str = str + 'T12:00:00';
    var d = new Date(str);
    return isNaN(d.getTime()) ? null : d;
};

/**
 * Normalise a daily forecast into an array of day records.
 * Accepts both shapes the platform and a proxy over it plausibly emit:
 *   columnar  { time: [...], temperature_2m_max: [...], ... }   (Roz-Weather-API /v1/daily)
 *   row-wise  [ { date, code, temp_max, ... }, ... ]
 */
var normaliseDays = function (raw) {
    var candidates = [
        raw && raw.daily,
        raw && raw.forecast,
        raw && raw.days,
        raw && raw.daily_forecast,
        raw && raw.data && raw.data.daily,
    ];

    var block = null;
    for (var i = 0; i < candidates.length; i++) {
        if (candidates[i]) { block = candidates[i]; break; }
    }
    if (!block) return [];

    var rows = [];

    if (Array.isArray(block)) {
        // Row-wise: one object per day.
        for (var r = 0; r < block.length; r++) {
            var d = block[r] || {};
            rows.push({
                date: toDate(pickVal([d], ['date', 'time', 'day', 'valid_date', 'timestamp'])),
                code: pickNum([d], ['weather_code', 'weathercode', 'code', 'wmo_code']),
                condition: pickVal([d], ['condition', 'summary', 'description', 'weather', 'label']),
                tempMax: pickNum([d], ['temp_max_c', 'temperature_2m_max', 'temp_max', 'tempMax', 'max_temp', 'high', 'max']),
                tempMin: pickNum([d], ['temp_min_c', 'temperature_2m_min', 'temp_min', 'tempMin', 'min_temp', 'low', 'min']),
                humidityMean: pickNum([d], ['humidity_mean_pct', 'relative_humidity_2m_mean', 'humidity_mean', 'humidity', 'rh_mean', 'rh', 'relative_humidity']),
                humidityMax: pickNum([d], ['humidity_max_pct', 'relative_humidity_2m_max', 'humidity_max', 'rh_max']),
                precip: pickNum([d], ['precipitation_mm', 'precipitation_sum', 'precipitation', 'precip', 'rain', 'rainfall_mm']),
                wetHours: pickNum([d], ['humid_hours_above_80', 'relative_humidity_hours_above_80', 'hours_above_80', 'damp_hours', 'wet_hours']),
                dewSpread: pickNum([d], ['dew_point_spread_min_c', 'dew_point_spread_min', 'dew_point_spread', 'dewpoint_spread']),
            });
        }
    } else if (typeof block === 'object') {
        // Columnar: parallel arrays keyed by variable name.
        var col = function (keys) {
            var v = pickVal([block], keys);
            return Array.isArray(v) ? v : null;
        };
        var times = col(['time', 'dates', 'date', 'days']) || [];
        var codes = col(['weather_code', 'weathercode', 'code']);
        var tmax = col(['temp_max_c', 'temperature_2m_max', 'temp_max', 'max_temp']);
        var tmin = col(['temp_min_c', 'temperature_2m_min', 'temp_min', 'min_temp']);
        var rhm = col(['humidity_mean_pct', 'relative_humidity_2m_mean', 'humidity_mean', 'humidity', 'rh_mean']);
        var rhx = col(['humidity_max_pct', 'relative_humidity_2m_max', 'humidity_max', 'rh_max']);
        var pre = col(['precipitation_mm', 'precipitation_sum', 'precipitation', 'precip', 'rain']);
        var wet = col(['humid_hours_above_80', 'relative_humidity_hours_above_80', 'hours_above_80', 'damp_hours']);
        var dew = col(['dew_point_spread_min_c', 'dew_point_spread_min', 'dew_point_spread']);
        var at = function (arr, idx) { return arr ? num(arr[idx]) : null; };

        for (var c = 0; c < times.length; c++) {
            rows.push({
                date: toDate(times[c]),
                code: at(codes, c),
                condition: null,
                tempMax: at(tmax, c),
                tempMin: at(tmin, c),
                humidityMean: at(rhm, c),
                humidityMax: at(rhx, c),
                precip: at(pre, c),
                wetHours: at(wet, c),
                dewSpread: at(dew, c),
            });
        }
    }

    return rows;
};

/**
 * wxPageMap — turn whatever /api/weather returned into the one shape this page renders.
 * Nothing below this function reads a raw API field. Every access is guarded; a payload
 * that is missing a key produces null, and the UI renders an em dash rather than crashing.
 */
// NAMESPACED. Both weather components originally called this `mapPayload`, and in an app
// with no module system every top-level `var` is a property of `window` — so whichever
// script loaded second silently replaced the other's mapper. The panel then ran the page's
// function, received a shape it did not understand, and rendered an empty card with
// nothing in the console. Keep weather globals `wx`-prefixed.
var wxPageMap = function (raw) {
    var r = raw && typeof raw === 'object' ? raw : {};

    // "Current conditions" have lived under half a dozen names across weather APIs.
    var cur = [
        r.current,
        r.current_weather,
        r.currentWeather,
        r.now,
        r.observation,
        r.current_conditions,
        r.data && r.data.current,
        r,
    ];

    // /api/weather sends observed days as a SEPARATE top-level `history` array, not mixed
    // into `daily` — deliberately, since the two come from different upstream feeds (a
    // reanalysis archive and a forecast model). The field names match wherever the
    // measurement is the same, so the two normalise identically and concatenate into one
    // series. `history` is [] when the archive is unavailable.
    var days = normaliseDays({ daily: r.history }).concat(normaliseDays(r));

    // Anything dated before today (local) is treated as history for the trend; the rest is
    // forecast. Note the archive runs several days behind — see the gap note below.
    var startOfToday = new Date();
    startOfToday.setHours(0, 0, 0, 0);
    var todayMs = startOfToday.getTime();

    var history = [];
    var forecast = [];
    for (var i = 0; i < days.length; i++) {
        var day = days[i];
        if (day.date && day.date.getTime() < todayMs) history.push(day);
        else forecast.push(day);
    }

    // Freshness. Prefer an explicit age from the API; fall back to a timestamp we can
    // subtract; otherwise we simply do not claim to know.
    var ageHours = pickNum([r.freshness, r, r.meta, r.cache], ['age_hours', 'ageHours', 'data_age_hours']);
    if (ageHours === null) {
        var ageSeconds = pickNum([r, r.meta, r.cache], ['age_seconds', 'ageSeconds', 'age']);
        if (ageSeconds !== null) ageHours = ageSeconds / 3600;
    }
    var updatedAt = toDate(pickVal([r, r.meta, r.cache], [
        'updated_at', 'updatedAt', 'fetched_at', 'fetchedAt', 'generated_at',
        'observation_time', 'last_updated', 'run', 'run_date', 'latest_run',
    ]));
    if (ageHours === null && updatedAt) {
        ageHours = (Date.now() - updatedAt.getTime()) / 3600000;
    }

    var staleFlag = pickVal([r.freshness, r, r.meta, r.cache], ['stale', 'is_stale', 'isStale', 'cached_stale']);
    var stale = staleFlag === true || staleFlag === 'true' || (ageHours !== null && ageHours >= 3);

    // Flood / severity. GloFAS via the platform returns a return period, not a word.
    var floodSrc = r.flood || r.river || r.flood_risk || null;
    var flood = null;
    if (floodSrc && typeof floodSrc === 'object') {
        var snapped = floodSrc.snapped || {};
        flood = {
            returnPeriod: pickNum([floodSrc], ['max_return_period_exceeded', 'return_period', 'exceeds', 'rp']),
            severity: pickVal([floodSrc], ['severity', 'level', 'status', 'category']),
            distanceKm: pickNum([snapped, floodSrc], ['distance_km', 'distanceKm']),
            river: pickVal([floodSrc, snapped], ['river', 'name', 'river_name']),
            attribution: pickVal([floodSrc], ['attribution']),
        };
    }

    var todayRow = forecast.length ? forecast[0] : (days.length ? days[days.length - 1] : null);

    return {
        location: {
            name: pickVal([r, r.location, r.place], ['location_name', 'place', 'name', 'city', 'suburb', 'label']),
            lat: pickNum([r, r.location], ['latitude', 'lat']),
            lon: pickNum([r, r.location], ['longitude', 'lon', 'lng']),
            timezone: pickVal([r], ['timezone', 'tz']),
        },
        current: {
            temp: pickNum(cur, ['temperature_2m', 'temperature', 'temp_c', 'temp', 'air_temperature']),
            feelsLike: pickNum(cur, ['apparent_temperature', 'feels_like', 'feelsLike', 'apparent_temp']),
            humidity: pickNum(cur, ['humidity_pct', 'relative_humidity_2m', 'relative_humidity', 'humidity', 'rh']),
            dewPoint: pickNum(cur, ['dew_point_2m', 'dew_point', 'dewpoint']),
            code: pickNum(cur, ['weather_code', 'weathercode', 'code', 'wmo_code']),
            conditionText: pickVal(cur, ['condition', 'summary', 'description', 'weather', 'text', 'label']),
            precip: pickNum(cur, ['precipitation', 'precip', 'rain', 'precipitation_sum']),
            wind: pickNum(cur, ['wind_speed_10m', 'wind_speed', 'windspeed', 'wind', 'wind_speed_10m_max']),
            // is_day: 1/0 from Open-Meteo-family payloads. null means "work it out from the clock".
            isDay: (function () {
                var v = pickVal(cur, ['is_day', 'isDay', 'daylight']);
                if (v === null) return null;
                if (v === true || v === 1 || v === '1' || v === 'true') return true;
                if (v === false || v === 0 || v === '0' || v === 'false') return false;
                return null;
            })(),
        },
        today: todayRow,
        history: history,
        forecast: forecast,
        allDays: days,
        flood: flood,
        stale: stale,
        ageHours: ageHours,
        updatedAt: updatedAt,
        attribution: pickVal([r, r.meta], ['attribution', 'source', 'credit']),
    };
};

// ---------------------------------------------------------------------------
// Weather codes → sky mood
// ---------------------------------------------------------------------------

// WMO 4677, the convention the platform API uses. mood drives both the animated hero and
// the small forecast glyphs, so there is one vocabulary rather than two.
var WMO = {
    0: ['clear', 'Clear sky'],
    1: ['clear', 'Mainly clear'],
    2: ['partly', 'Partly cloudy'],
    3: ['cloud', 'Overcast'],
    45: ['fog', 'Fog'],
    48: ['fog', 'Rime fog'],
    51: ['drizzle', 'Light drizzle'],
    53: ['drizzle', 'Drizzle'],
    55: ['drizzle', 'Dense drizzle'],
    56: ['drizzle', 'Freezing drizzle'],
    57: ['drizzle', 'Freezing drizzle'],
    61: ['rain', 'Light rain'],
    63: ['rain', 'Rain'],
    65: ['rain', 'Heavy rain'],
    66: ['rain', 'Freezing rain'],
    67: ['rain', 'Freezing rain'],
    71: ['snow', 'Light snow'],
    73: ['snow', 'Snow'],
    75: ['snow', 'Heavy snow'],
    77: ['snow', 'Snow grains'],
    80: ['rain', 'Showers'],
    81: ['rain', 'Showers'],
    82: ['rain', 'Violent showers'],
    85: ['snow', 'Snow showers'],
    86: ['snow', 'Snow showers'],
    95: ['storm', 'Thunderstorm'],
    96: ['storm', 'Storm with hail'],
    99: ['storm', 'Storm with hail'],
};

/** Text fallback for payloads that send a phrase instead of (or as well as) a code. */
// Shared with the dashboard panel via WeatherData.condition, so one table decodes WMO
// codes for every weather surface.
var codeMood = function (code) {
    return (typeof WeatherData !== 'undefined') ? WeatherData.condition(code) : 'unknown';
};

var moodFromText = function (text) {
    if (!text) return null;
    var t = String(text).toLowerCase();
    if (t.indexOf('thunder') >= 0 || t.indexOf('storm') >= 0) return 'storm';
    if (t.indexOf('snow') >= 0 || t.indexOf('sleet') >= 0 || t.indexOf('hail') >= 0) return 'snow';
    if (t.indexOf('drizzle') >= 0) return 'drizzle';
    if (t.indexOf('rain') >= 0 || t.indexOf('shower') >= 0) return 'rain';
    if (t.indexOf('fog') >= 0 || t.indexOf('mist') >= 0 || t.indexOf('haze') >= 0) return 'fog';
    if (t.indexOf('overcast') >= 0 || t.indexOf('cloud') >= 0) {
        return t.indexOf('partly') >= 0 || t.indexOf('part ') >= 0 ? 'partly' : 'cloud';
    }
    if (t.indexOf('clear') >= 0 || t.indexOf('sun') >= 0 || t.indexOf('fine') >= 0) return 'clear';
    return null;
};

var describe = function (code, text) {
    var entry = code !== null && code !== undefined ? WMO[code] : null;
    if (entry) return { mood: entry[0], label: entry[1] };
    var m = moodFromText(text);
    if (m) {
        return { mood: m, label: text ? String(text) : m };
    }
    return { mood: 'cloud', label: text ? String(text) : 'Conditions unavailable' };
};

// ---------------------------------------------------------------------------
// Palette
// ---------------------------------------------------------------------------

// Every sky colour below is a Mould Detect token or a straight tint/shade of one, mixed
// with the helper underneath. Nothing here introduces a colour the app does not already own.
var TOKENS = {
    forest: '#1a4332',
    primary: '#0fbd80',
    sage: '#86a697',
    terracotta: '#D4836B',
    bg: '#F7F6F3',
    surface: '#ffffff',
    accent: '#C0D6BA',
    muted: '#8BA888',
    warmLight: '#FDECE8',
    // Sun palette lifted verbatim from the skycard reference
    // (Roz-Weather-API/demo/skycard/icons.js). It is deliberately NOT the brand
    // terracotta: a sun tinted to #D4836B reads as a pale pink disc, which is
    // what this was before. Warm gold is the one place the weather view departs
    // from the palette, because a sun has to look like a sun.
    sunCore: '#fff3c4',
    sunMid:  '#ffd35c',
    sunEdge: '#ffb703',
    sunRay:  '#ffd873',
    // Same reasoning as the sun: rain is blue, lightning is yellow, snow is
    // white. Rendering them in brand tints gave off-white rain and a pale pink
    // lightning bolt, which read as a rendering fault rather than as weather.
    rain:      '#7ec8ff',
    drizzle:   '#a8d8ff',
    snow:      '#ffffff',
    lightning: '#ffe066',
    night: '#0d2019',
};

var hexToRgb = function (hex) {
    var h = hex.replace('#', '');
    return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
};

/** Linear blend of two hex colours. t = 0 keeps a, t = 1 keeps b. */
var mix = function (a, b, t) {
    var ca = hexToRgb(a);
    var cb = hexToRgb(b);
    var out = '#';
    for (var i = 0; i < 3; i++) {
        var v = Math.round(ca[i] + (cb[i] - ca[i]) * t);
        out += ('0' + Math.max(0, Math.min(255, v)).toString(16)).slice(-2);
    }
    return out;
};

// Sky gradient stops (top, middle, horizon) per mood, in daylight.
var SKY_DAY = {
    clear: [mix(TOKENS.forest, TOKENS.sage, 0.55), TOKENS.sage, TOKENS.accent],
    partly: [mix(TOKENS.forest, TOKENS.sage, 0.65), mix(TOKENS.sage, TOKENS.accent, 0.35), TOKENS.accent],
    cloud: [mix(TOKENS.forest, TOKENS.sage, 0.75), TOKENS.sage, mix(TOKENS.accent, TOKENS.bg, 0.4)],
    fog: [TOKENS.sage, mix(TOKENS.sage, TOKENS.bg, 0.55), TOKENS.bg],
    drizzle: [mix(TOKENS.forest, TOKENS.sage, 0.5), mix(TOKENS.sage, TOKENS.muted, 0.5), TOKENS.accent],
    rain: [mix(TOKENS.forest, TOKENS.sage, 0.35), mix(TOKENS.forest, TOKENS.sage, 0.7), TOKENS.sage],
    snow: [mix(TOKENS.forest, TOKENS.sage, 0.6), mix(TOKENS.sage, TOKENS.bg, 0.35), TOKENS.bg],
    storm: [mix(TOKENS.forest, TOKENS.night, 0.55), TOKENS.forest, mix(TOKENS.forest, TOKENS.sage, 0.55)],
};

/**
 * Resolve the three sky stops for a mood and a time of day.
 * Dusk and dawn warm the horizon towards terracotta; night pulls every stop down towards
 * the darkest shade of forest. The mood table stays small because the phase is applied
 * arithmetically rather than being enumerated.
 */
var skyStops = function (mood, phase) {
    var base = SKY_DAY[mood] || SKY_DAY.cloud;
    var stops = [base[0], base[1], base[2]];
    if (phase === 'night') {
        return [
            mix(stops[0], TOKENS.night, 0.78),
            mix(stops[1], TOKENS.night, 0.68),
            mix(stops[2], TOKENS.night, 0.55),
        ];
    }
    if (phase === 'dusk' || phase === 'dawn') {
        return [
            mix(stops[0], TOKENS.night, 0.35),
            mix(stops[1], TOKENS.forest, 0.25),
            mix(stops[2], TOKENS.terracotta, phase === 'dusk' ? 0.55 : 0.4),
        ];
    }
    return stops;
};

/** Time-of-day phase. Uses the API's is_day flag when it sends one, the clock otherwise. */
var phaseFor = function (hour, isDay) {
    if (isDay === false) return 'night';
    if (hour >= 5 && hour < 8) return 'dawn';
    if (hour >= 8 && hour < 17) return 'day';
    if (hour >= 17 && hour < 20) return 'dusk';
    if (isDay === true) return 'day';
    return 'night';
};

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

// Injected rather than added to index.html because another agent owns that file. All rules
// are prefixed wx- so nothing here can reach another component. Every animation is
// transform/opacity only (compositor-friendly) and every one of them is switched off under
// prefers-reduced-motion.
var WX_CSS = [
    '.wx-hero-svg{position:absolute;inset:0;width:100%;height:100%;display:block;}',
    '.wx-sun-rays{transform-origin:240px 52px;animation:wxSpin 20s linear infinite;}',
    '@keyframes wxSpin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}',
    '.wx-drift-a{animation:wxDriftA 34s ease-in-out infinite;}',
    '.wx-drift-b{animation:wxDriftB 52s ease-in-out infinite;}',
    '.wx-drift-c{animation:wxDriftA 44s ease-in-out infinite;animation-delay:-12s;}',
    '@keyframes wxDriftA{0%,100%{transform:translateX(-14px);}50%{transform:translateX(14px);}}',
    '@keyframes wxDriftB{0%,100%{transform:translateX(16px);}50%{transform:translateX(-16px);}}',
    '.wx-drop{animation:wxDrop 1.15s linear infinite;animation-delay:var(--wx-d,0s);opacity:0;}',
    '@keyframes wxDrop{0%{opacity:0;transform:translateY(-10px);}25%{opacity:.75;}100%{opacity:0;transform:translateY(58px);}}',
    '.wx-flash{animation:wxFlash 7s ease-in-out infinite;opacity:0;}',
    '@keyframes wxFlash{0%,86%,100%{opacity:0;}88%{opacity:.9;}90%{opacity:.15;}92%{opacity:.8;}95%{opacity:0;}}',
    '.wx-twinkle{animation:wxTwinkle 5s ease-in-out infinite;animation-delay:var(--wx-d,0s);}',
    '@keyframes wxTwinkle{0%,100%{opacity:.25;}50%{opacity:.9;}}',
    '.wx-fogband{animation:wxFog 26s ease-in-out infinite;animation-delay:var(--wx-d,0s);}',
    '@keyframes wxFog{0%,100%{transform:translateX(-26px);}50%{transform:translateX(26px);}}',
    '.wx-flake{animation:wxFlake 6s ease-in-out infinite;animation-delay:var(--wx-d,0s);}',
    '@keyframes wxFlake{0%{opacity:0;transform:translate(0,-6px);}30%{opacity:.85;}100%{opacity:0;transform:translate(8px,52px);}}',
    '.wx-rise{animation:wxRise .55s cubic-bezier(.22,.8,.3,1) both;}',
    '@keyframes wxRise{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:translateY(0);}}',
    '.wx-halo{animation:wxHalo 2.8s ease-in-out infinite;transform-box:fill-box;transform-origin:center;}',
    '@keyframes wxHalo{0%,100%{opacity:.5;transform:scale(1);}50%{opacity:.12;transform:scale(1.9);}}',
    '.wx-shimmer{background:linear-gradient(90deg,rgba(134,166,151,.10) 25%,rgba(134,166,151,.22) 37%,rgba(134,166,151,.10) 63%);background-size:400% 100%;animation:wxShimmer 1.5s ease infinite;}',
    '@keyframes wxShimmer{0%{background-position:100% 0;}100%{background-position:-100% 0;}}',
    // Stale data is desaturated as well as labelled, so it reads as "not now" before the
    // note is read.
    '.wx-stale{filter:saturate(.5);}',
    '.wx-focusable:focus-visible{outline:2px solid #0fbd80;outline-offset:2px;border-radius:12px;}',
    '@media (prefers-reduced-motion: reduce){',
    '.wx-sun-rays,.wx-drift-a,.wx-drift-b,.wx-drift-c,.wx-drop,.wx-flash,.wx-twinkle,',
    '.wx-fogband,.wx-flake,.wx-rise,.wx-halo,.wx-shimmer{animation:none !important;}',
    '.wx-drop,.wx-flash,.wx-flake{opacity:.5 !important;}',
    '.wx-rise{opacity:1 !important;transform:none !important;}',
    '}',
].join('');

// ---------------------------------------------------------------------------
// Sky primitives — every shape is a circle/ellipse/rect/line, none of it hand-authored paths
// ---------------------------------------------------------------------------

/** A puffy cloud built from four overlapping ellipses. */
var Cloud = function (key, cx, cy, s, fill, opacity) {
    return (
        <g key={key} fill={fill} opacity={opacity}>
            <ellipse cx={cx} cy={cy} rx={34 * s} ry={15 * s} />
            <ellipse cx={cx - 24 * s} cy={cy + 6 * s} rx={22 * s} ry={11 * s} />
            <ellipse cx={cx + 26 * s} cy={cy + 5 * s} rx={24 * s} ry={12 * s} />
            <ellipse cx={cx + 3 * s} cy={cy - 11 * s} rx={19 * s} ry={13 * s} />
        </g>
    );
};

// Star field is a fixed table rather than Math.random so the sky does not reshuffle on
// every re-render (the freshness ticker re-renders this component every minute).
var STARS = [
    [26, 30, 1.2, 0], [58, 18, 0.9, 1.4], [92, 42, 1.1, 0.6], [128, 24, 0.8, 2.1],
    [166, 38, 1.3, 1.1], [198, 20, 0.9, 2.6], [232, 44, 1.0, 0.3], [268, 28, 1.2, 1.8],
    [298, 50, 0.9, 0.9], [44, 62, 1.0, 2.3], [148, 62, 0.8, 3.1], [214, 68, 1.1, 1.6],
];

/**
 * SkyHero — the animated sky. Mood picks the actors (sun, cloud count, rain, lightning,
 * fog bands); phase picks the palette and swaps sun for moon.
 */
var SkyHero = function (props) {
    var mood = props.mood;
    var phase = props.phase;
    var label = props.label;

    var stops = skyStops(mood, phase);
    var isNight = phase === 'night';
    var isDim = isNight || mood === 'storm' || mood === 'rain';

    var cloudFill = isDim ? mix(TOKENS.bg, TOKENS.forest, 0.55) : TOKENS.bg;
    var cloudBack = isDim ? mix(TOKENS.bg, TOKENS.forest, 0.7) : mix(TOKENS.bg, TOKENS.sage, 0.35);

    var showLuminary = mood === 'clear' || mood === 'partly';
    var cloudCount = mood === 'clear' ? 0
        : mood === 'partly' ? 2
        : mood === 'fog' ? 1
        : 3;

    var drops = [];
    if (mood === 'rain' || mood === 'drizzle' || mood === 'storm') {
        var dropCount = mood === 'drizzle' ? 10 : mood === 'storm' ? 16 : 14;
        for (var i = 0; i < dropCount; i++) {
            var dx = 8 + (i * 307) % 310;
            var dy = 108 + (i % 4) * 12;
            drops.push(
                <line
                    key={'d' + i}
                    className="wx-drop"
                    x1={dx} y1={dy} x2={dx - 3} y2={dy + (mood === 'drizzle' ? 6 : 12)}
                    stroke={mood === 'drizzle' ? TOKENS.drizzle : TOKENS.rain}
                    strokeWidth={mood === 'drizzle' ? 1.1 : 1.7}
                    strokeLinecap="round"
                    style={{ '--wx-d': ((i * 0.11) % 1.15).toFixed(2) + 's' }}
                />
            );
        }
    }

    var flakes = [];
    if (mood === 'snow') {
        for (var f = 0; f < 12; f++) {
            flakes.push(
                <circle
                    key={'f' + f}
                    className="wx-flake"
                    cx={14 + (f * 271) % 300}
                    cy={104 + (f % 3) * 14}
                    r={1.7}
                    fill={TOKENS.snow}
                    style={{ '--wx-d': ((f * 0.42) % 5.4).toFixed(2) + 's' }}
                />
            );
        }
    }

    var clouds = [];
    for (var c = 0; c < cloudCount; c++) {
        var layout = [
            { x: 96, y: 74, s: 1.0, cls: 'wx-drift-a', fill: cloudFill, o: 0.92 },
            { x: 232, y: 96, s: 0.78, cls: 'wx-drift-b', fill: cloudBack, o: 0.7 },
            { x: 40, y: 108, s: 0.62, cls: 'wx-drift-c', fill: cloudBack, o: 0.55 },
        ][c];
        clouds.push(
            <g key={'c' + c} className={layout.cls}>
                {Cloud('cl' + c, layout.x, layout.y, layout.s, layout.fill, layout.o)}
            </g>
        );
    }

    var fogBands = [];
    if (mood === 'fog') {
        for (var b = 0; b < 3; b++) {
            fogBands.push(
                <rect
                    key={'fb' + b}
                    className="wx-fogband"
                    x={-30} y={104 + b * 26} width={380} height={13} rx={6.5}
                    fill="url(#wxgFog)"
                    opacity={0.55 - b * 0.12}
                    style={{ '--wx-d': (b * 3.5) + 's' }}
                />
            );
        }
    }

    var rays = [];
    if (showLuminary && !isNight) {
        for (var rr = 0; rr < 8; rr++) {
            rays.push(
                <rect
                    key={'r' + rr}
                    x={238} y={3} width={4} height={14} rx={2}
                    fill={TOKENS.sunRay}
                    opacity={0.9}
                    transform={'rotate(' + (rr * 45) + ' 240 52)'}
                />
            );
        }
    }

    return (
        <svg
            className="wx-hero-svg"
            viewBox="0 0 320 200"
            preserveAspectRatio="xMidYMid slice"
            role="img"
            aria-label={label}
            focusable="false"
        >
            <defs>
                <linearGradient id="wxgSky" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0%" stopColor={stops[0]} />
                    <stop offset="55%" stopColor={stops[1]} />
                    <stop offset="100%" stopColor={stops[2]} />
                </linearGradient>
                <radialGradient id="wxgSun" cx="0.35" cy="0.3" r="0.8">
                    <stop offset="0%" stopColor={TOKENS.sunCore} />
                    <stop offset="55%" stopColor={TOKENS.sunMid} />
                    <stop offset="100%" stopColor={TOKENS.sunEdge} />
                </radialGradient>
                <radialGradient id="wxgMoon" cx="0.38" cy="0.32" r="0.8">
                    <stop offset="0%" stopColor={TOKENS.bg} />
                    <stop offset="100%" stopColor={TOKENS.accent} />
                </radialGradient>
                <radialGradient id="wxgGlow" cx="0.5" cy="0.5" r="0.5">
                    <stop offset="0%" stopColor={isNight ? TOKENS.accent : TOKENS.sunMid} stopOpacity="0.18" />
                    <stop offset="100%" stopColor={isNight ? TOKENS.accent : TOKENS.sunMid} stopOpacity="0" />
                </radialGradient>
                <linearGradient id="wxgFog" x1="0" y1="0" x2="1" y2="0">
                    <stop offset="0%" stopColor={TOKENS.bg} stopOpacity="0" />
                    <stop offset="45%" stopColor={TOKENS.bg} stopOpacity="0.9" />
                    <stop offset="100%" stopColor={TOKENS.bg} stopOpacity="0" />
                </linearGradient>
                <linearGradient id="wxgScrim" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0%" stopColor={TOKENS.forest} stopOpacity="0" />
                    <stop offset="100%" stopColor={TOKENS.forest} stopOpacity="0.62" />
                </linearGradient>
            </defs>

            <rect x="0" y="0" width="320" height="200" fill="url(#wxgSky)" />

            {isNight && STARS.map(function (s, i) {
                return (
                    <circle
                        key={'s' + i}
                        className="wx-twinkle"
                        cx={s[0]} cy={s[1]} r={s[2]}
                        fill={TOKENS.bg}
                        style={{ '--wx-d': s[3] + 's' }}
                    />
                );
            })}

            {showLuminary && (
                <g>
                    <circle cx="240" cy="52" r="58" fill="url(#wxgGlow)" />
                    {!isNight && <g className="wx-sun-rays">{rays}</g>}
                    <circle cx="240" cy="52" r="23" fill={isNight ? 'url(#wxgMoon)' : 'url(#wxgSun)'} />
                    {isNight && (
                        <g fill={mix(TOKENS.accent, TOKENS.sage, 0.5)} opacity="0.5">
                            <circle cx="233" cy="46" r="4" />
                            <circle cx="247" cy="58" r="3" />
                            <circle cx="243" cy="42" r="2" />
                        </g>
                    )}
                </g>
            )}

            {clouds}
            {fogBands}
            {drops}
            {flakes}

            {mood === 'storm' && (
                <polygon
                    className="wx-flash"
                    points="168,84 150,120 165,120 154,154 190,110 172,110"
                    fill={TOKENS.lightning}
                />
            )}

            {/* Scrim: guarantees the overlaid temperature keeps its contrast against any sky. */}
            <rect x="0" y="96" width="320" height="104" fill="url(#wxgScrim)" />
        </svg>
    );
};

/** Small static glyph for the forecast strip. Not animated — 7 of these on one screen. */
var WxGlyph = function (props) {
    var mood = props.mood;
    var size = props.size || 28;
    var cloudFill = TOKENS.sage;
    var sunFill = TOKENS.terracotta;

    var body = null;
    if (mood === 'clear') {
        body = (
            <g>
                <circle cx="16" cy="16" r="7.5" fill={sunFill} />
                <g fill={sunFill} opacity="0.6">
                    {[0, 45, 90, 135, 180, 225, 270, 315].map(function (a) {
                        return <rect key={a} x="15" y="1.5" width="2" height="4.5" rx="1" transform={'rotate(' + a + ' 16 16)'} />;
                    })}
                </g>
            </g>
        );
    } else if (mood === 'partly') {
        body = (
            <g>
                <circle cx="21" cy="11" r="6" fill={sunFill} />
                <g fill={cloudFill}>
                    <ellipse cx="14" cy="21" rx="9" ry="5" />
                    <ellipse cx="8" cy="23" rx="6" ry="4" />
                    <ellipse cx="20" cy="23" rx="6" ry="4" />
                </g>
            </g>
        );
    } else if (mood === 'fog') {
        body = (
            <g fill={cloudFill}>
                <ellipse cx="16" cy="13" rx="10" ry="5.5" />
                <rect x="4" y="20" width="24" height="2.6" rx="1.3" opacity="0.75" />
                <rect x="7" y="25" width="18" height="2.6" rx="1.3" opacity="0.5" />
            </g>
        );
    } else if (mood === 'snow') {
        body = (
            <g>
                <g fill={cloudFill}>
                    <ellipse cx="16" cy="14" rx="10" ry="5.5" />
                    <ellipse cx="9" cy="16" rx="6" ry="4" />
                    <ellipse cx="23" cy="16" rx="6" ry="4" />
                </g>
                <g fill={TOKENS.accent}>
                    <circle cx="11" cy="25" r="1.8" />
                    <circle cx="16" cy="28" r="1.8" />
                    <circle cx="21" cy="25" r="1.8" />
                </g>
            </g>
        );
    } else if (mood === 'storm') {
        body = (
            <g>
                <g fill={mix(TOKENS.sage, TOKENS.forest, 0.4)}>
                    <ellipse cx="16" cy="13" rx="10" ry="5.5" />
                    <ellipse cx="9" cy="15" rx="6" ry="4" />
                    <ellipse cx="23" cy="15" rx="6" ry="4" />
                </g>
                <polygon points="17,19 12,26 15.5,26 13.5,31 20,23 16.5,23" fill={TOKENS.terracotta} />
            </g>
        );
    } else if (mood === 'rain' || mood === 'drizzle') {
        body = (
            <g>
                <g fill={mood === 'rain' ? mix(TOKENS.sage, TOKENS.forest, 0.35) : cloudFill}>
                    <ellipse cx="16" cy="13" rx="10" ry="5.5" />
                    <ellipse cx="9" cy="15" rx="6" ry="4" />
                    <ellipse cx="23" cy="15" rx="6" ry="4" />
                </g>
                <g stroke={TOKENS.primary} strokeWidth="2" strokeLinecap="round">
                    <line x1="11" y1="22" x2="10" y2="27" />
                    <line x1="16" y1="23" x2="15" y2="29" />
                    <line x1="21" y1="22" x2="20" y2="27" />
                </g>
            </g>
        );
    } else {
        body = (
            <g fill={cloudFill}>
                <ellipse cx="16" cy="15" rx="10" ry="5.5" />
                <ellipse cx="9" cy="17" rx="6" ry="4" />
                <ellipse cx="23" cy="17" rx="6" ry="4" />
            </g>
        );
    }

    return (
        <svg width={size} height={size} viewBox="0 0 32 32" role="img" aria-label={props.label || ''} focusable="false">
            {body}
        </svg>
    );
};

// ---------------------------------------------------------------------------
// Humidity trend chart
// ---------------------------------------------------------------------------

/**
 * HumidityTrend — the most valuable thing on the page. A fixed 20–100% domain (rather than
 * a fitted one) is deliberate: it keeps the 70% "damp" reference line in a consistent place
 * so the shape means the same thing on every visit.
 */
var HumidityTrend = function (props) {
    var points = props.points || []; // [{ label, value, future }]
    if (points.length < 2) return null;

    var vw = 320, vh = 152;
    var padL = 30, padR = 12, padT = 12, padB = 26;
    var w = vw - padL - padR;
    var h = vh - padT - padB;
    var lo = 20, hi = 100;

    // Spaced by DATE, not by index. The observed archive runs several days behind the
    // forecast — currently a six-day gap — and evenly-spaced points would draw a straight
    // line across it as though those days had been measured. Falls back to index spacing
    // when the caller supplies no usable dates.
    var xs = [];
    var t0 = null, t1 = null;
    for (var xi = 0; xi < points.length; xi++) {
        var pt = points[xi] && points[xi].date;
        var ms = pt && pt.getTime ? pt.getTime() : null;
        xs.push(ms);
        if (ms !== null) {
            if (t0 === null || ms < t0) t0 = ms;
            if (t1 === null || ms > t1) t1 = ms;
        }
    }
    var span = t0 !== null && t1 !== null && t1 > t0 ? t1 - t0 : 0;
    var xAt = function (i) {
        if (span && xs[i] !== null) return padL + ((xs[i] - t0) / span) * w;
        return padL + (i / (points.length - 1)) * w;
    };
    var yAt = function (v) {
        var clamped = Math.max(lo, Math.min(hi, v));
        return padT + h - ((clamped - lo) / (hi - lo)) * h;
    };

    var lineD = '';
    var areaD = '';
    for (var i = 0; i < points.length; i++) {
        var x = xAt(i).toFixed(1);
        var y = yAt(points[i].value).toFixed(1);
        lineD += (i === 0 ? 'M' : 'L') + x + ' ' + y + ' ';
        areaD += (i === 0 ? 'M' : 'L') + x + ' ' + y + ' ';
    }
    areaD += 'L' + xAt(points.length - 1).toFixed(1) + ' ' + (padT + h).toFixed(1) + ' ';
    areaD += 'L' + xAt(0).toFixed(1) + ' ' + (padT + h).toFixed(1) + ' Z';

    // The emphasised endpoint is "now" — the last non-forecast reading, or simply the last.
    var endIdx = points.length - 1;
    for (var e = points.length - 1; e >= 0; e--) {
        if (!points[e].future) { endIdx = e; break; }
    }
    var endX = xAt(endIdx);
    var endY = yAt(points[endIdx].value);

    var gridValues = [40, 60, 80];
    var labelIdx = [0, Math.floor((points.length - 1) / 2), points.length - 1];

    var values = points.map(function (p) { return p.value; });
    var minV = Math.round(Math.min.apply(null, values));
    var maxV = Math.round(Math.max.apply(null, values));

    var summary = 'Humidity trend: ranges from ' + minV + ' to ' + maxV +
        ' percent across ' + points.length + ' readings, ' +
        Math.round(points[endIdx].value) + ' percent at the latest reading.';

    return (
        <div>
            <svg
                viewBox={'0 0 ' + vw + ' ' + vh}
                className="w-full wx-rise"
                style={{ height: 'auto' }}
                role="img"
                aria-label={summary}
                focusable="false"
            >
                <defs>
                    <linearGradient id="wxgArea" x1="0" y1="0" x2="0" y2="1">
                        <stop offset="0%" stopColor={TOKENS.primary} stopOpacity="0.30" />
                        <stop offset="100%" stopColor={TOKENS.primary} stopOpacity="0.02" />
                    </linearGradient>
                </defs>

                {gridValues.map(function (g) {
                    return (
                        <g key={'g' + g}>
                            <line
                                x1={padL} y1={yAt(g)} x2={vw - padR} y2={yAt(g)}
                                stroke={TOKENS.accent} strokeWidth="1" opacity="0.55"
                            />
                            <text
                                x={padL - 6} y={yAt(g) + 3.5} textAnchor="end"
                                fill={TOKENS.sage} fontSize="9" fontWeight="700"
                            >{g}</text>
                        </g>
                    );
                })}

                {/* 70% is where sustained damp starts to matter indoors — marked, not implied. */}
                <line
                    x1={padL} y1={yAt(70)} x2={vw - padR} y2={yAt(70)}
                    stroke={TOKENS.terracotta} strokeWidth="1.2" strokeDasharray="4 4" opacity="0.65"
                />
                <text x={vw - padR} y={yAt(70) - 5} textAnchor="end" fill={TOKENS.terracotta} fontSize="8.5" fontWeight="800">
                    DAMP 70%
                </text>

                <path d={areaD} fill="url(#wxgArea)" />
                <path d={lineD} fill="none" stroke={TOKENS.primary} strokeWidth="2.4" strokeLinejoin="round" strokeLinecap="round" />

                <circle className="wx-halo" cx={endX} cy={endY} r="5" fill={TOKENS.primary} />
                <circle cx={endX} cy={endY} r="4.5" fill={TOKENS.surface} stroke={TOKENS.primary} strokeWidth="2.4" />

                {labelIdx.map(function (li, n) {
                    if (n > 0 && li === labelIdx[n - 1]) return null;
                    return (
                        <text
                            key={'l' + li}
                            x={xAt(li)}
                            y={vh - 8}
                            textAnchor={li === 0 ? 'start' : li === points.length - 1 ? 'end' : 'middle'}
                            fill={TOKENS.sage} fontSize="9" fontWeight="700"
                        >{points[li].label}</text>
                    );
                })}
            </svg>
            {/* Text alternative — the chart is never the only carrier of this information. */}
            <p className="text-[11px] text-sage mt-1 leading-relaxed">{summary}</p>
        </div>
    );
};

// ---------------------------------------------------------------------------
// Mould risk read
// ---------------------------------------------------------------------------

/**
 * assessDamp — a plain, explainable read on whether the weather is setting up damp.
 *
 * Deliberately simple and additive so the "why" panel underneath can list the exact
 * contributing facts. This describes outdoor conditions and what they tend to mean for a
 * building. It is not a measurement of any room, and it is not a health judgement.
 */
var assessDamp = function (mapped) {
    var reasons = [];
    var score = 0;

    var today = mapped.today;
    var humidity = mapped.current.humidity;
    if (humidity === null && today) humidity = today.humidityMean;

    if (humidity !== null) {
        if (humidity >= 85) { score += 3; reasons.push({ icon: 'humidity_high', text: 'Humidity is very high right now (' + Math.round(humidity) + '%).' }); }
        else if (humidity >= 75) { score += 2; reasons.push({ icon: 'humidity_high', text: 'Humidity is high right now (' + Math.round(humidity) + '%).' }); }
        else if (humidity >= 65) { score += 1; reasons.push({ icon: 'humidity_percentage', text: 'Humidity is moderate (' + Math.round(humidity) + '%).' }); }
        else { reasons.push({ icon: 'humidity_low', text: 'Humidity is comfortably low (' + Math.round(humidity) + '%).' }); }
    }

    // Sustained damp matters more than a single humid afternoon — mould needs time.
    var recent = mapped.history.slice(-6).concat(mapped.forecast.slice(0, 1));
    var dampDays = 0;
    var counted = 0;
    for (var i = 0; i < recent.length; i++) {
        if (recent[i].humidityMean === null || recent[i].humidityMean === undefined) continue;
        counted++;
        if (recent[i].humidityMean >= 70) dampDays++;
    }
    if (counted >= 2) {
        if (dampDays >= 4) { score += 3; reasons.push({ icon: 'calendar_month', text: dampDays + ' of the last ' + counted + ' days averaged above 70% humidity.' }); }
        else if (dampDays >= 2) { score += 2; reasons.push({ icon: 'calendar_month', text: dampDays + ' of the last ' + counted + ' days averaged above 70% humidity.' }); }
        else if (dampDays === 1) { score += 1; reasons.push({ icon: 'calendar_month', text: 'One of the last ' + counted + ' days averaged above 70% humidity.' }); }
        else { reasons.push({ icon: 'calendar_month', text: 'No day in the last ' + counted + ' averaged above 70% humidity.' }); }
    }

    // Recent rain keeps external walls and subfloors wet for days after it stops.
    var rain = 0;
    var rainDays = 0;
    var rainWindow = mapped.history.slice(-3);
    for (var j = 0; j < rainWindow.length; j++) {
        if (rainWindow[j].precip !== null && rainWindow[j].precip !== undefined) { rain += rainWindow[j].precip; rainDays++; }
    }
    if (rainDays > 0) {
        if (rain >= 20) { score += 2; reasons.push({ icon: 'rainy', text: Math.round(rain) + ' mm of rain over the last ' + rainDays + ' days.' }); }
        else if (rain >= 5) { score += 1; reasons.push({ icon: 'rainy', text: Math.round(rain) + ' mm of rain over the last ' + rainDays + ' days.' }); }
        else { reasons.push({ icon: 'rainy', text: 'Little rain in the last ' + rainDays + ' days.' }); }
    }

    // Opt-in derived variable when the endpoint forwards it — hours per day at RH >= 80.
    var wetHours = null;
    var wetCount = 0;
    var wetSum = 0;
    for (var k = 0; k < recent.length; k++) {
        if (recent[k].wetHours !== null && recent[k].wetHours !== undefined) { wetSum += recent[k].wetHours; wetCount++; }
    }
    if (wetCount > 0) {
        wetHours = wetSum / wetCount;
        if (wetHours >= 10) { score += 1; reasons.push({ icon: 'schedule', text: 'Around ' + Math.round(wetHours) + ' hours a day above 80% humidity.' }); }
        else reasons.push({ icon: 'schedule', text: 'Around ' + Math.round(wetHours) + ' hours a day above 80% humidity.' });
    }

    var band, headline, detail;
    // No facts, no verdict. Without this, a payload missing humidity would render a
    // confident "Low" — the most damaging thing this page could get wrong.
    if (reasons.length === 0) {
        return {
            band: 'Unknown',
            score: 0,
            headline: 'Not enough data to read damp conditions.',
            detail: 'The weather service did not return humidity or rainfall for this location, so there is nothing here to base a read on. Try again later, or judge by what you can see and smell in the room itself.',
            reasons: [],
            known: false,
        };
    }
    if (score >= 7) {
        band = 'High';
        headline = 'The weather is actively favouring damp.';
        detail = 'Humidity has stayed high and there has been recent rain. In these conditions moisture lingers on cool surfaces — external walls, window reveals, behind furniture — and that is where growth usually starts. Ventilating on a dry, breezy hour does more than opening up when the air outside is already saturated.';
    } else if (score >= 5) {
        band = 'Elevated';
        headline = 'Conditions are leaning damp.';
        detail = 'Humidity has been up for long enough to matter. This is the pattern that lets mould establish rather than a single humid day. Worth keeping air moving through the rooms that already feel cold or stuffy.';
    } else if (score >= 3) {
        band = 'Moderate';
        headline = 'Humidity is up, but not sustained.';
        detail = 'Nothing here needs action today. Airing rooms out after showers and cooking is the whole job at this level.';
    } else {
        band = 'Low';
        headline = 'The weather is not driving damp right now.';
        detail = 'Air is dry enough that outdoor conditions are working in your favour. If something indoors is still damp, the cause is more likely a leak, a cold bridge or poor ventilation than the weather.';
    }

    return {
        band: band,
        score: score,
        headline: headline,
        detail: detail,
        reasons: reasons,
        known: reasons.length > 0,
    };
};

var BAND_STYLE = {
    Unknown: { bg: '#F1F3F0', text: '#5d7a6b', dot: TOKENS.sage },
    Low: { bg: '#EAF7F1', text: '#0f6b4a', dot: TOKENS.primary },
    Moderate: { bg: '#F1F6EC', text: '#3d5a44', dot: TOKENS.muted },
    Elevated: { bg: TOKENS.warmLight, text: '#8c4a33', dot: TOKENS.terracotta },
    High: { bg: TOKENS.warmLight, text: '#7a3b26', dot: TOKENS.terracotta },
};

// ---------------------------------------------------------------------------
// Small presentational helpers
// ---------------------------------------------------------------------------

var DASH = '—';

var fmtTemp = function (v) { return v === null || v === undefined ? DASH : Math.round(v) + '°'; };
var fmtPct = function (v) { return v === null || v === undefined ? DASH : Math.round(v) + '%'; };
var fmtMm = function (v) { return v === null || v === undefined ? DASH : (v < 10 ? v.toFixed(1) : Math.round(v)) + ' mm'; };

/** True when a date falls on the local calendar day we are in right now. */
var isSameLocalDay = function (date) {
    if (!date) return false;
    var now = new Date();
    return date.getFullYear() === now.getFullYear() &&
        date.getMonth() === now.getMonth() &&
        date.getDate() === now.getDate();
};

// Labelled from the date itself, not from the array index: a payload whose forecast begins
// tomorrow would otherwise print "Today" over tomorrow's numbers.
var dayLabel = function (date) {
    if (!date) return DASH;
    if (isSameLocalDay(date)) return 'Today';
    return date.toLocaleDateString(undefined, { weekday: 'short' });
};

var Stat = function (props) {
    return (
        <div className="flex-1 min-w-0 text-center">
            <p className="text-[9.5px] font-black uppercase tracking-[0.1em] text-sage">{props.label}</p>
            <p className="text-sm font-extrabold text-forest mt-0.5 truncate">{props.value}</p>
        </div>
    );
};

var Card = function (props) {
    return (
        <section className={'bio-bg ' + (props.tint || 'bio-bg-20') +
            ' rounded-xl p-5 border border-stone-100/50 shadow-soft ' + (props.className || '')}>
            {props.title && (
                <div className="flex items-center gap-2 mb-3">
                    <span className="material-symbols-outlined text-forest text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>{props.icon}</span>
                    <h2 className="text-sm font-extrabold text-forest">{props.title}</h2>
                </div>
            )}
            {props.children}
        </section>
    );
};

// ---------------------------------------------------------------------------
// WeatherPage
// ---------------------------------------------------------------------------

// Single source of truth for the approximate-location fallback (AppConstants
// loads earlier in index.html's script order, so this is safe at module level).
var FALLBACK_COORDS = AppConstants.FALLBACK_COORDS;

var WeatherPage = function () {
    var statusState = _useState('loading');      // loading | ready | error
    var status = statusState[0];
    var setStatus = statusState[1];

    var dataState = _useState(null);             // mapped payload
    var data = dataState[0];
    var setData = dataState[1];

    var errorState = _useState('');
    var errorMsg = errorState[0];
    var setErrorMsg = errorState[1];

    var placeState = _useState(null);            // { name, approximate }
    var place = placeState[0];
    var setPlace = placeState[1];

    var fetchedState = _useState(null);          // Date we received the payload
    var fetchedAt = fetchedState[0];
    var setFetchedAt = fetchedState[1];

    var tickState = _useState(Date.now());       // drives the "x ago" text
    var setTick = tickState[1];

    var abortRef = _useRef(null);
    var mountedRef = _useRef(true);

    // Both refresh controls route through this so the event fires wherever the
    // user actually tapped, rather than only on the one that happened to be wired.
    var refresh = function () {
        if (typeof AnalyticsService !== 'undefined') {
            AnalyticsService.weatherRefreshed({
                surface: 'page',
                // `freshness` is a var in this same scope, assigned during render and
                // read here at click time, so it is populated by the time this runs.
                was_stale: !!(freshness && freshness.stale),
            });
        }
        load();
    };

    var load = _useCallback(function () {
        setStatus('loading');
        setErrorMsg('');

        if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; }
        var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
        abortRef.current = controller;

        // Stale-while-revalidate: paint a cached reading immediately (if one is valid —
        // see WeatherCache.MAX_AGE_MS), then fall through to the network fetch below
        // regardless, which seamlessly replaces it when fresh data lands. This page has
        // no `ok` flag on its mapped shape the way the dashboard panel does (wxPageMap
        // always returns a fully-shaped object, nulls where data is missing), so "valid"
        // here means the cache actually carried a usable reading rather than an empty one.
        var cachePainted = false;
        // Guards the (rare) reverse race: an IndexedDB read usually resolves in
        // single-digit milliseconds, but a cold database open can take ~100ms — long
        // enough for a fast network response to land first. A late cache read must
        // never paint stale data over a fresh reading that is already on screen.
        var networkLanded = false;
        if (typeof WeatherCache !== 'undefined' && WeatherCache.load) {
            WeatherCache.load().then(function (rec) {
                if (!mountedRef.current || !rec || networkLanded) return;
                var cachedMapped;
                try { cachedMapped = wxPageMap(rec.raw); } catch (e) { cachedMapped = null; }
                var usable = cachedMapped && (
                    (cachedMapped.current && (cachedMapped.current.temp !== null || cachedMapped.current.humidity !== null)) ||
                    !!cachedMapped.today ||
                    (cachedMapped.forecast && cachedMapped.forecast.length > 0) ||
                    (cachedMapped.history && cachedMapped.history.length > 0)
                );
                if (usable) {
                    cachePainted = true;
                    setData(cachedMapped);
                    setStatus('ready');
                    if (rec.place) setPlace({ name: rec.place, approximate: false });
                }
            })['catch'](function () { /* a broken cache read must never block the network path below */ });
        }

        // Coordinates used for the network request, captured here so the success handler
        // below can pass them to WeatherCache.save() without re-deriving them.
        var lastCoords = null;

        var coordsPromise = (window.GeoLocationService && GeoLocationService.getCurrentPosition)
            ? GeoLocationService.getCurrentPosition()
            : Promise.reject(new Error('Location service unavailable.'));

        coordsPromise
            .then(function (coords) {
                return { lat: coords.lat, lon: coords.lon, approximate: false };
            })
            .catch(function () {
                // A denied or unavailable location must not leave an empty page. Fall back
                // to a default and say so, rather than presenting it as "here".
                return { lat: FALLBACK_COORDS.lat, lon: FALLBACK_COORDS.lon, approximate: true };
            })
            .then(function (coords) {
                if (!mountedRef.current) return null;

                if (coords.approximate) {
                    setPlace({ name: FALLBACK_COORDS.name, approximate: true });
                } else if (window.GeoLocationService && GeoLocationService.reverseGeocode) {
                    // Naming the place is a nicety — it must never block the forecast, and a
                    // Nominatim failure is not a page failure.
                    GeoLocationService.reverseGeocode(coords.lat, coords.lon)
                        .then(function (geo) {
                            if (!mountedRef.current) return;
                            setPlace({ name: geo.displayName || geo.city || geo.suburb || '', approximate: false });
                        })
                        .catch(function () { /* keep whatever the payload provided */ });
                }

                lastCoords = coords;
                var url = '/api/weather?lat=' + encodeURIComponent(coords.lat.toFixed(4)) +
                    '&lon=' + encodeURIComponent(coords.lon.toFixed(4));

                return fetch(url, controller ? { signal: controller.signal } : undefined)
                    .then(function (res) {
                        if (!res.ok) throw new Error('The weather service returned ' + res.status + '.');
                        return res.json();
                    });
            })
            .then(function (raw) {
                if (!mountedRef.current || raw === null) return;
                var mapped = wxPageMap(raw);
                networkLanded = true;
                setData(mapped);
                setFetchedAt(new Date());
                setStatus('ready');
                // Persist the verbatim raw payload, not the mapped view — the dashboard
                // panel runs its own independent mapper over the same cached raw response.
                if (typeof WeatherCache !== 'undefined' && WeatherCache.save && lastCoords) {
                    WeatherCache.save(lastCoords.lat, lastCoords.lon, (mapped.location && mapped.location.name) || null, raw);
                }
                if (typeof AnalyticsService !== 'undefined') {
                    var risk = null;
                    try { risk = assessDamp(mapped); } catch (e) { risk = null; }
                    AnalyticsService.weatherLoaded({
                        surface:      'page',
                        approximate:  !!(mapped && mapped.approximate),
                        stale:        !!(mapped && mapped.stale),
                        humidity_pct: (mapped && mapped.current && mapped.current.humidity !== null &&
                                       mapped.current.humidity !== undefined) ? mapped.current.humidity : -1,
                        damp_risk:    (risk && risk.level) ? risk.level : 'unknown',
                    });
                }
            })
            .catch(function (err) {
                if (!mountedRef.current) return;
                if (err && err.name === 'AbortError') return;
                // A cached reading is already on screen — a background refresh failure is
                // not news to the user, and regressing a visible reading to the error state
                // would be a worse outcome than just leaving it slightly stale.
                if (!cachePainted) {
                    setErrorMsg((err && err.message) || 'Could not load the weather.');
                    setStatus('error');
                }
                if (typeof AnalyticsService !== 'undefined') {
                    AnalyticsService.weatherUnavailable({
                        surface: 'page',
                        reason: (err && err.message) ? String(err.message).slice(0, 80) : 'request_failed',
                    });
                }
            });
    }, []);

    _useEffect(function () {
        mountedRef.current = true;
        load();

        // Keeps "updated 9h ago" honest without re-fetching. Cleared below — this app has
        // a documented history of timers outliving the view that started them.
        var tickId = setInterval(function () {
            if (mountedRef.current) setTick(Date.now());
        }, 60000);

        return function () {
            mountedRef.current = false;
            clearInterval(tickId);
            if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; }
        };
    }, [load]);

    // -- derived view model ------------------------------------------------

    var conditions = _useMemo(function () {
        if (!data) return { mood: 'cloud', label: 'Loading' };
        var code = data.current.code;
        if (code === null && data.today) code = data.today.code;
        return describe(code, data.current.conditionText || (data.today && data.today.condition));
    }, [data]);

    var phase = _useMemo(function () {
        return phaseFor(new Date().getHours(), data ? data.current.isDay : null);
    }, [data]);

    var heroLabel = conditions.label + ' sky, ' + (phase === 'day' ? 'daytime' : phase) +
        (data && data.current.temp !== null ? ', ' + Math.round(data.current.temp) + ' degrees' : '');

    var trendPoints = _useMemo(function () {
        if (!data) return [];
        var rows = data.history.slice(-8).concat(data.forecast.slice(0, 3));
        var out = [];
        var todayMs = (function () { var d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); })();
        for (var i = 0; i < rows.length; i++) {
            var v = rows[i].humidityMean;
            if (v === null || v === undefined) v = rows[i].humidityMax;
            if (v === null || v === undefined) continue;
            out.push({
                label: rows[i].date ? rows[i].date.toLocaleDateString(undefined, { weekday: 'narrow' }) : '',
                value: v,
                // Carried through so the chart can space points by date rather than by
                // index — the observed archive lags the forecast by several days.
                date: rows[i].date || null,
                future: rows[i].date ? rows[i].date.getTime() > todayMs : false,
            });
        }
        return out;
    }, [data]);

    var risk = _useMemo(function () {
        return data ? assessDamp(data) : null;
    }, [data, tickState[0]]);

    var freshness = _useMemo(function () {
        if (!data) return null;
        if (data.ageHours !== null && data.ageHours !== undefined) {
            var h = data.ageHours;
            if (h < 1) return { text: 'Updated ' + Math.max(1, Math.round(h * 60)) + ' min ago', stale: data.stale };
            if (h < 48) return { text: 'Updated ' + Math.round(h) + 'h ago', stale: data.stale };
            return { text: 'Updated ' + Math.round(h / 24) + ' days ago', stale: true };
        }
        if (data.updatedAt) {
            var mins = Math.round((Date.now() - data.updatedAt.getTime()) / 60000);
            if (mins < 60) return { text: 'Updated ' + Math.max(1, mins) + ' min ago', stale: data.stale };
            return { text: 'Updated ' + Math.round(mins / 60) + 'h ago', stale: data.stale };
        }
        // The API told us nothing about age, so we only claim what we know: when we asked.
        if (fetchedAt) {
            return {
                text: 'Loaded ' + fetchedAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
                stale: false,
                unknownAge: true,
            };
        }
        return null;
    }, [data, fetchedAt, tickState[0]]);

    var locationName = (place && place.name) || (data && data.location.name) || '';
    var isApprox = !!(place && place.approximate);

    // -- states ------------------------------------------------------------

    var renderLoading = function () {
        return (
            <div className="px-6 pt-2 space-y-4" aria-busy="true" aria-live="polite">
                <div className="wx-shimmer rounded-xl" style={{ height: '232px' }} />
                <div className="wx-shimmer rounded-xl" style={{ height: '210px' }} />
                <div className="wx-shimmer rounded-xl" style={{ height: '120px' }} />
                <p className="sr-only">Loading weather</p>
            </div>
        );
    };

    var renderError = function () {
        return (
            <div className="px-6 pt-6" role="alert">
                <Card icon="cloud_off" title="Weather unavailable" tint="bio-bg-30">
                    <p className="text-sm text-forest leading-relaxed">{errorMsg}</p>
                    <p className="text-xs text-sage leading-relaxed mt-2">
                        The rest of the app works without it {DASH} the weather read is context, not a dependency.
                    </p>
                    <button
                        type="button"
                        onClick={refresh}
                        className="wx-focusable btn-nature mt-4 inline-flex items-center gap-2 px-4 py-2.5 rounded-full bg-forest text-white text-sm font-extrabold"
                    >
                        <span className="material-symbols-outlined text-base">refresh</span>
                        Try again
                    </button>
                </Card>
            </div>
        );
    };

    var renderReady = function () {
        var bandStyle = BAND_STYLE[risk.band] || BAND_STYLE.Moderate;
        var humidityNow = data.current.humidity;
        if (humidityNow === null && data.today) humidityNow = data.today.humidityMean;

        return (
            <div className="px-6 pt-2 space-y-4">

                {/* ---- Animated sky hero ---- */}
                <div
                    className={'relative rounded-xl overflow-hidden shadow-soft wx-rise ' + (freshness && freshness.stale ? 'wx-stale' : '')}
                    style={{ height: '232px' }}
                >
                    <SkyHero mood={conditions.mood} phase={phase} label={heroLabel} />

                    <div className="relative h-full flex flex-col justify-end p-5">
                        <p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-white/85 flex items-center gap-1">
                            <span className="material-symbols-outlined text-sm">location_on</span>
                            {locationName || 'Your area'}
                            {isApprox && <span className="font-bold normal-case tracking-normal opacity-80">(approximate)</span>}
                        </p>
                        <div className="flex items-end gap-3 mt-1">
                            <p className="font-display text-6xl font-bold text-white leading-none tracking-tight">
                                {fmtTemp(data.current.temp !== null ? data.current.temp : (data.today ? data.today.tempMax : null))}
                            </p>
                            <div className="pb-1.5">
                                <p className="text-sm font-extrabold text-white leading-tight">{conditions.label}</p>
                                {data.current.feelsLike !== null && (
                                    <p className="text-xs text-white/80 leading-tight">Feels like {fmtTemp(data.current.feelsLike)}</p>
                                )}
                            </div>
                        </div>
                    </div>
                </div>

                {/* ---- Stale-data warning ----
                     Shown ONLY when the reading is actually old. It used to render on
                     every load, reporting "Loaded 16:38" and, when the API omitted a
                     timestamp, "the service did not report a reading time" — which tells a
                     homeowner nothing they can act on and reads as a fault. That is
                     plumbing detail, and it is gone.

                     What survives is the case that genuinely matters: weather that may
                     have changed since it was measured. Never present stale readings as
                     current — the panel header states this as a rule and it still holds.
                     Refresh is unaffected; the header carries its own refresh control. */}
                {freshness && freshness.stale && (
                    <div
                        className="flex items-center gap-2 px-3 py-2 rounded-lg"
                        style={{ background: TOKENS.warmLight }}
                    >
                        <span
                            className="material-symbols-outlined text-base"
                            style={{ color: TOKENS.terracotta }}
                            aria-hidden="true"
                        >
                            history
                        </span>
                        <p className="text-[11px] font-bold leading-snug flex-1" style={{ color: '#8c4a33' }}>
                            {freshness.text} — conditions may have changed since.
                        </p>
                        <button
                            type="button"
                            onClick={refresh}
                            aria-label="Refresh weather"
                            className="wx-focusable btn-nature shrink-0 w-8 h-8 rounded-full bg-surface flex items-center justify-center"
                        >
                            <span className="material-symbols-outlined text-base text-forest">refresh</span>
                        </button>
                    </div>
                )}

                {/* ---- Humidity: the headline for a mould app ---- */}
                <Card icon="humidity_percentage" title="Humidity" tint="bio-bg-10">
                    <div className="flex items-end gap-4">
                        <div>
                            <p className="font-display text-5xl font-bold text-forest leading-none tracking-tight">
                                {fmtPct(humidityNow)}
                            </p>
                            <p className="text-[11px] font-bold text-sage mt-1">
                                {humidityNow === null ? 'Not reported'
                                    : humidityNow >= 80 ? 'Very humid'
                                    : humidityNow >= 70 ? 'Humid'
                                    : humidityNow >= 55 ? 'Comfortable'
                                    : 'Dry'}
                            </p>
                        </div>
                        <p className="text-xs text-forest leading-relaxed flex-1 pb-1">
                            Humidity, not temperature, is what mould responds to. Sustained readings above 70% are
                            the ones worth watching.
                        </p>
                    </div>

                    {trendPoints.length >= 2 ? (
                        <div className="mt-4">
                            <HumidityTrend points={trendPoints} />
                        </div>
                    ) : (
                        <p className="text-xs text-sage mt-4 leading-relaxed">
                            No humidity history available for this location yet, so there is no trend to show.
                        </p>
                    )}
                </Card>

                {/* ---- Mould risk read ---- */}
                <Card icon="eco" title="What this means for damp" tint="bio-bg-30">
                    <div
                        className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full mb-3"
                        style={{ background: bandStyle.bg }}
                    >
                        <span className="w-2 h-2 rounded-full" style={{ background: bandStyle.dot }} aria-hidden="true" />
                        <span className="text-[11px] font-black uppercase tracking-[0.1em]" style={{ color: bandStyle.text }}>
                            {risk.known ? risk.band + ' damp pressure' : 'Damp read unavailable'}
                        </span>
                    </div>

                    <p className="text-sm font-extrabold text-forest leading-snug">{risk.headline}</p>
                    <p className="text-sm text-forest leading-relaxed mt-2">{risk.detail}</p>

                    {risk.reasons.length > 0 && (
                        <div className="mt-4 pt-4 border-t border-sage/15 space-y-2">
                            <p className="text-[10px] font-black uppercase tracking-[0.12em] text-sage">What this is based on</p>
                            {risk.reasons.map(function (r, i) {
                                return (
                                    <div key={i} className="flex items-start gap-2.5">
                                        <span className="material-symbols-outlined text-sage text-base shrink-0 mt-0.5" aria-hidden="true">{r.icon}</span>
                                        <p className="text-xs text-forest leading-relaxed">{r.text}</p>
                                    </div>
                                );
                            })}
                        </div>
                    )}

                    <p className="text-[11px] text-sage leading-relaxed mt-4">
                        This reads outdoor conditions. Indoors depends on heating, ventilation, and how much moisture
                        the household adds {DASH} showers, cooking, drying washing inside. A read on the weather is not a
                        reading of your rooms, and it is not medical advice.
                    </p>
                </Card>

                {/* ---- Forecast strip ---- */}
                {data.forecast.length > 0 && (
                    <Card icon="calendar_month" title="Next few days" tint="bio-bg-40">
                        <div className="overflow-x-auto no-scrollbar -mx-1">
                            <div className="flex gap-2 px-1 min-w-min">
                                {data.forecast.slice(0, 7).map(function (d, i) {
                                    var info = describe(d.code, d.condition);
                                    var isToday = isSameLocalDay(d.date);
                                    return (
                                        <div
                                            key={i}
                                            className={'shrink-0 w-[68px] rounded-md py-3 px-1 flex flex-col items-center gap-1.5 ' +
                                                (isToday ? 'bg-background-light border border-stone-200/70' : '')}
                                        >
                                            <span className="text-[10px] font-black uppercase tracking-wider text-sage">
                                                {dayLabel(d.date)}
                                            </span>
                                            <WxGlyph mood={info.mood} size={30} label={info.label} />
                                            <span className="text-[11px] font-extrabold text-forest">{fmtTemp(d.tempMax)}</span>
                                            <span className="text-[10px] font-bold text-sage">{fmtTemp(d.tempMin)}</span>
                                            <span
                                                className="text-[10px] font-black mt-0.5"
                                                style={{ color: (d.humidityMean !== null && d.humidityMean >= 70) ? TOKENS.terracotta : TOKENS.muted }}
                                            >
                                                {fmtPct(d.humidityMean)}
                                            </span>
                                        </div>
                                    );
                                })}
                            </div>
                        </div>
                        <p className="text-[11px] text-sage mt-3 leading-relaxed">
                            The bottom figure on each day is average humidity. Days shown in terracotta averaged above 70%.
                        </p>
                    </Card>
                )}

                {/* ---- Today's numbers ---- */}
                <Card icon="analytics" title="Today" tint="bio-bg-20">
                    <div className="flex gap-2">
                        <Stat label="High" value={fmtTemp(data.today ? data.today.tempMax : null)} />
                        <Stat label="Low" value={fmtTemp(data.today ? data.today.tempMin : null)} />
                        <Stat label="Rain" value={fmtMm(data.today ? data.today.precip : data.current.precip)} />
                        <Stat label="Wind" value={data.current.wind === null ? DASH : Math.round(data.current.wind) + ' km/h'} />
                    </div>
                </Card>

                {/* ---- Flood / river severity, only when the service actually sent it ---- */}
                {data.flood && (data.flood.returnPeriod !== null || data.flood.severity) && (
                    <Card icon="water" title="River flood signal" tint="bio-bg-50">
                        <p className="text-sm text-forest leading-relaxed">
                            {data.flood.returnPeriod !== null
                                ? 'The nearest modelled river is running at or above its 1-in-' + data.flood.returnPeriod + '-year flow.'
                                : 'Reported severity: ' + data.flood.severity + '.'}
                            {data.flood.distanceKm !== null && ' The nearest river cell is about ' + data.flood.distanceKm.toFixed(1) + ' km away.'}
                        </p>
                        <p className="text-[11px] text-sage leading-relaxed mt-2">
                            Flooding matters here for one reason: materials that have been wet stay wet for weeks, and
                            that is when mould follows. {data.flood.attribution || 'Copernicus Emergency Management Service'}
                        </p>
                    </Card>
                )}

                {/* ---- Footer ---- */}
                {/* forest, not sage: this sits on the green page ground, where light text
                    breaks the legibility golden rule (light text only on solid dark fills) */}
                <p className="text-[10.5px] text-forest/80 font-medium text-center leading-relaxed pt-1">
                    {data.attribution || 'Weather and flood data via the Mould Detect data platform.'}
                    <br />
                    Decision support only {DASH} not a substitute for professional or medical advice.
                </p>
            </div>
        );
    };

    return (
        <Layout>
            <main className="flex-1 overflow-y-auto overflow-x-hidden pb-36">
                <style dangerouslySetInnerHTML={{ __html: WX_CSS }} />
                <PageHeader title="Weather" showMenu={false} />
                {status === 'loading' && renderLoading()}
                {status === 'error' && renderError()}
                {status === 'ready' && data && risk && renderReady()}
            </main>
        </Layout>
    );
};

window.WeatherPage = WeatherPage;
