// ============================================================================
// SHARED AURORA Kp DATA SERVICE
// Used by both weather.html and planning.html so there is exactly ONE place
// that knows how to fetch NOAA's Kp forecast safely. Before this file existed,
// weather.html and planning.html each had their own separate copy of this
// logic, and only weather.html's had been hardened against real production
// incidents (see history below) - planning.html's copy was quietly missing
// those same protections. Loading this one file from both pages means a fix
// made here benefits both immediately, and they can never drift apart again.
//
// Real incidents this hardening exists because of:
//   - NOAA retired the old mag-7-day/plasma-7-day endpoints ~30 Apr 2026
//     without warning; every fetch 404'd and Promise.allSettled silently
//     swallowed it, so the Aurora panel just stopped updating with no error
//     visible anywhere.
//   - NOAA's forecast-generation job itself has been observed to silently
//     stall (frozen ~11 days behind, once) while still returning 200 OK with
//     syntactically valid JSON. Every timestamp in the file was in the past,
//     which meant "closest entry to now" logic would pick a several-day-old
//     point and confidently display it as current - or worse, a "max Kp
//     tonight" search would find zero points in the (correctly, currently
//     dark) window and confidently render "Kp 0.0", which reads as "checked,
//     and it's quiet" rather than the true state, "we don't actually know."
//
// Because of that second incident specifically, this module deliberately
// distinguishes "no aurora activity" (a real 0) from "no usable data" (null).
// Callers must not treat a null point list as "Kp 0" - see maxKpInWindow and
// closestPoint below, both of which return null rather than guessing.
// ============================================================================

const AURORA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min - matches both pages' previous cache windows
const AURORA_FORECAST_STALE_IF_NO_FUTURE_POINT = true; // see fetchAuroraKpPoints below
const AURORA_RECENT_STALE_THRESHOLD_MS = 6 * 60 * 60 * 1000; // 6 hours
const AURORA_GFZ_FALLBACK_LOOKBACK_MS = 24 * 60 * 60 * 1000; // 24 hours

let _auroraCache = null; // { points, source, fetchedAt }

const _auroraFetchWithTimeout = (url, ms = 6000) => {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error('Timeout')), ms);
        fetch(url, { cache: 'no-store' })
            .then(res => { clearTimeout(timer); resolve(res); })
            .catch(err => { clearTimeout(timer); reject(err); });
    });
};

// Parses NOAA's two possible JSON shapes for this product: the legacy
// [["time_tag","kp",...], [t, kp, ...], ...] array-of-arrays with a header
// row, and the current plain array-of-objects with no header row. Handling
// both means a future NOAA format change (they have form on this) doesn't
// silently drop every point again the way the 30 Apr 2026 incident did.
function _parseAuroraKpJson(arr) {
    const points = [];
    if (!Array.isArray(arr)) return points;
    for (let i = 0; i < arr.length; i++) {
        const row = arr[i];
        if (!row) continue;
        let dateStr = String(row.time_tag || row.time || (Array.isArray(row) ? row[0] : ''));
        if (!dateStr || dateStr === 'time_tag') continue;
        dateStr = dateStr.replace(' ', 'T');
        if (!dateStr.endsWith('Z')) dateStr += 'Z';
        const kp = parseFloat(Array.isArray(row) ? row[1] : (row.predicted_kp ?? row.kp ?? row.estimated_kp ?? row.kp_index ?? row.Kp));
        if (isNaN(kp)) continue;
        const time = new Date(dateStr).getTime();
        if (isNaN(time)) continue;
        // observed/noaaScale are optional extras only present on the NOAA
        // forecast product's raw rows - carried through as-is for callers
        // (planning.html's confidence calculation) that want them; callers
        // that only need {time, kp} (weather.html) simply ignore them.
        const point = { time, kp };
        if (!Array.isArray(row)) {
            if (row.observed !== undefined) point.observed = row.observed;
            if (row.noaa_scale !== undefined) point.noaaScale = row.noaa_scale;
        }
        points.push(point);
    }
    return points;
}

/**
 * Fetches the most reliable available Kp data points, trying sources in
 * order and applying a freshness check to each before accepting it:
 *   1. NOAA's observed/"recent" Kp - accepted only if its newest point is
 *      within AURORA_RECENT_STALE_THRESHOLD_MS of right now.
 *   2. NOAA's forecast Kp - accepted only if it has at least one point still
 *      in the future (a healthy forecast always does; if every timestamp in
 *      it is in the past, the generation job has stalled).
 *   3. GFZ Potsdam's independent nowcast, as a last resort if both NOAA
 *      sources failed or were too stale to trust. GFZ is the IAGA-endorsed
 *      official keeper of the Kp index - NOAA's own product is itself partly
 *      derived from GFZ's contributing observatory network - and it's
 *      updated independently, so a NOAA-side stall doesn't take this down
 *      too. Trade-off: near-real-time only, not a multi-day forecast, so it
 *      can rescue "right now" but not "three days from now."
 *
 * Returns { points: [{time, kp}], source: 'noaa-recent'|'noaa-forecast'|'gfz-fallback', fetchedAt }
 * or { points: [], source: null, fetchedAt } if nothing usable was found -
 * callers must treat an empty result as "we don't know", never as "Kp 0".
 */
async function fetchAuroraKpPoints() {
    if (_auroraCache && (Date.now() - _auroraCache.fetchedAt) < AURORA_CACHE_TTL_MS) {
        return _auroraCache;
    }

    let recentPoints = [];
    let forecastPoints = [];

    try {
        const res = await _auroraFetchWithTimeout('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json', 6000);
        if (res.ok) {
            const text = await res.text();
            if (text) recentPoints = _parseAuroraKpJson(JSON.parse(text));
        }
    } catch (e) {
        console.warn('Aurora Kp (NOAA recent/observed) unavailable:', e.message);
    }

    try {
        const res = await _auroraFetchWithTimeout('https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json', 6000);
        if (res.ok) {
            const text = await res.text();
            if (text) forecastPoints = _parseAuroraKpJson(JSON.parse(text));
        }
    } catch (e) {
        console.warn('Aurora Kp (NOAA forecast) unavailable:', e.message);
    }

    const now = Date.now();
    let usablePoints = [];
    let source = null;

    const newestRecent = recentPoints.length ? Math.max(...recentPoints.map(p => p.time)) : NaN;
    if (!isNaN(newestRecent) && (now - newestRecent) <= AURORA_RECENT_STALE_THRESHOLD_MS) {
        usablePoints = usablePoints.concat(recentPoints);
        source = 'noaa-recent';
    }

    const newestForecast = forecastPoints.length ? Math.max(...forecastPoints.map(p => p.time)) : NaN;
    const forecastIsStale = isNaN(newestForecast) || newestForecast < now;
    if (!forecastIsStale) {
        usablePoints = usablePoints.concat(forecastPoints);
        source = source ? source + '+noaa-forecast' : 'noaa-forecast';
    }

    if (usablePoints.length === 0) {
        try {
            const end = new Date();
            const start = new Date(end.getTime() - AURORA_GFZ_FALLBACK_LOOKBACK_MS);
            const fmt = (d) => d.toISOString().slice(0, 19);
            const res = await _auroraFetchWithTimeout(`https://kp.gfz.de/app/json/?start=${fmt(start)}Z&end=${fmt(end)}Z&index=Kp`, 6000);
            if (res.ok) {
                const gfz = await res.json();
                if (gfz && gfz.Kp && gfz.datetime) {
                    gfz.datetime.forEach((t, i) => {
                        const kp = gfz.Kp[i];
                        if (kp === null || kp === undefined) return;
                        const time = new Date(t).getTime();
                        if (!isNaN(time)) usablePoints.push({ time, kp });
                    });
                    if (usablePoints.length > 0) source = 'gfz-fallback';
                }
            }
        } catch (e) {
            console.warn('Aurora Kp (GFZ Potsdam nowcast fallback) also unavailable:', e.message);
        }
    }

    _auroraCache = { points: usablePoints, source, fetchedAt: now };
    return _auroraCache;
}

/**
 * The single point closest to targetDate, for callers that want "what's the
 * forecast for this one specific moment" (planning.html's use case).
 * Returns null if there are no points at all, or if the closest one is more
 * than maxDiffMs away (default 36h) - a forecast entry a week away from the
 * requested time isn't a meaningful answer to "what's it doing then", it's a
 * sign the data doesn't actually cover that moment.
 */
function closestAuroraPoint(points, targetDate, maxDiffMs = 36 * 3600000) {
    if (!points || points.length === 0) return null;
    let closest = null;
    let closestDiff = Infinity;
    const targetTime = targetDate.getTime();
    points.forEach(p => {
        const diff = Math.abs(p.time - targetTime);
        if (diff < closestDiff) { closestDiff = diff; closest = p; }
    });
    if (!closest || closestDiff > maxDiffMs) return null;
    return closest;
}

/**
 * The highest Kp reached within [startTime, endTime), for callers building a
 * "max Kp tonight" style view (weather.html's use case). Returns null if no
 * available data point reaches as far as startTime at all - that's "we don't
 * know", not "it was quiet" - and only returns a real 0 if data does cover
 * the window and nothing in it exceeded 0. This distinction is exactly what
 * the "frozen 11 days behind" incident (see file header) was silently
 * getting wrong before.
 */
function maxAuroraKpInWindow(points, startTime, endTime) {
    if (!points || points.length === 0) return null;
    const latestDataTime = Math.max(...points.map(p => p.time));
    if (startTime > latestDataTime) return null;

    let max = 0;
    let found = false;
    points.forEach(p => {
        if (p.time >= startTime && p.time <= endTime) {
            if (p.kp > max) max = p.kp;
            found = true;
        }
    });
    return found ? max : 0;
}
