/**
 * api.js — Fetch helpers with SWR cache, optimistic POST, and system-error tracking.
 *
 * api(url)  — returns stale cache immediately, refreshes in background (SWR).
 * post(url) — accepts optional `optimistic` record; reconciles on server reply.
 */

import { CACHE_TTL, S, bus } from './state.js';

const _cache = new Map();
const DEFAULT_TIMEOUT_MS = 5_000;

function _timeoutFor(url, override) {
    if (Number.isFinite(Number(override))) return Number(override);
    if (url.includes('/api/tokens/')) return 1_800;
    if (url.includes('/api/license')) return 2_500;
    if (url.includes('/api/runtimes')) return 3_000;
    if (url.includes('/api/dashboard/surface/')) return 4_000;
    if (url.includes('/api/dashboard/summary')) return 4_500;
    if (url.includes('/api/knowledge-topology')) return 7_000;
    return DEFAULT_TIMEOUT_MS;
}

function _scopedUrl(url) {
    if (typeof url !== 'string' || !url.startsWith('/api/')) return url;
    const runtimeId = S.scope?.runtimeId;
    if (!runtimeId) return url;
    try {
        const scoped = new URL(url, window.location.origin);
        if (!scoped.searchParams.has('runtimeId')) scoped.searchParams.set('runtimeId', runtimeId);
        return scoped.pathname + scoped.search + scoped.hash;
    } catch {
        return url;
    }
}

/**
 * GET with SWR semantics: return cached data immediately (if within TTL),
 * then kick off a background refresh.  Callers get the fast synchronous hit
 * for p50 <5ms; the next render cycle gets the fresh value.
 */
export async function api(url, ttl = CACHE_TTL, opts = {}) {
    const requestUrl = _scopedUrl(url);
    const hit = _cache.get(requestUrl);
    const fresh = !hit || (Date.now() - hit.ts >= ttl);

    if (hit) {
        if (fresh) {
            _fetch(requestUrl, opts).catch(() => {});
            bus.emit('api:stale', { url: requestUrl, ageMs: Date.now() - hit.ts });
        } else {
            _fetch(requestUrl, opts).catch(() => {});
        }
        return hit.data;
    }

    return _fetch(requestUrl, opts);
}

async function _fetch(url, opts = {}) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), _timeoutFor(url, opts.timeoutMs));
    try {
        const r = await fetch(url, { signal: controller.signal });
        if (!r.ok) return null;
        const d = await r.json();
        _cache.set(url, { data: d, ts: Date.now() });
        bus.emit('cache:updated', url);
        return d;
    } catch (err) {
        bus.emit('api:timeout', { url, error: err?.name === 'AbortError' ? 'timeout' : String(err?.message || err) });
        return null;
    } finally {
        clearTimeout(timeout);
    }
}

/**
 * POST with JSON body.
 * @param {string} url
 * @param {object} body
 * @param {{ optimistic?: object }} [opts]  — If `optimistic` is provided, the
 *   optimistic record is returned immediately; the real response replaces it.
 * @returns {Promise<{ ok: boolean, data: any, error: string|null, status: number }>}
 */
export async function post(url, body, opts = {}) {
    const { optimistic } = opts;
    const requestUrl = _scopedUrl(url);
    // If an optimistic record is provided, return it right away and let the
    // caller reconcile once the real response arrives.
    const req = fetch(requestUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
    }).then(async (r) => {
        if (r.ok) {
            const data = await r.json().catch(() => ({}));
            return { ok: true, data, error: null, status: r.status };
        }
        let errText = `HTTP ${r.status}`;
        let errData = null;
        try {
            errData = await r.json();
            errText = errData?.error || errData?.message || errText;
        } catch {
            try { errText = (await r.text()) || errText; } catch { /* ignore */ }
        }
        return { ok: false, data: errData, error: errText, status: r.status };
    }).catch((e) => ({ ok: false, data: null, error: e?.message || String(e), status: 0 }));

    if (optimistic !== undefined) {
        // Return immediately with optimistic data; the caller can await the
        // real result separately if it needs to reconcile.
        req.then((real) => { if (!real.ok) bus.emit('post:optimistic-fail', { url: requestUrl, error: real.error }); });
        return { ok: true, data: optimistic, error: null, status: 202, realResponse: req };
    }
    return req;
}

/** Invalidate a cached URL so the next api() call fetches fresh. */
export function bustCache(url) {
    _cache.delete(url);
    const scoped = _scopedUrl(url);
    if (scoped !== url) _cache.delete(scoped);
}

/** Clear the entire cache (e.g. after an SSE reconnect). */
export function clearCache() {
    _cache.clear();
}

/* ── System-error tracking (replaces one-shot notifyNotReady toast) ── */

/**
 * Push a system error into S.systemErrors so the persistent error banner
 * renders it.  Deduplicated by code.  Use bus.emit('systemErrors') to
 * trigger banner re-render.
 */
export function pushSystemError(code, message, hint) {
    const existing = S.systemErrors.findIndex((e) => e.code === code);
    const entry = { code, message: message || code, hint, at: Date.now() };
    if (existing >= 0) S.systemErrors[existing] = entry;
    else S.systemErrors.push(entry);
    bus.emit('systemErrors', S.systemErrors);
}

/** Clear a system error by code (e.g. after a successful reinit). */
export function clearSystemError(code) {
    S.systemErrors = S.systemErrors.filter((e) => e.code !== code);
    bus.emit('systemErrors', S.systemErrors);
}

/**
 * For each payload that has `notReady: true`, push a persistent system error
 * instead of a one-shot toast.  (CLAUDE.md §3 — Pillar 2 disconnected.)
 */
export function notifyNotReady(payloads) {
    for (const p of payloads) {
        if (!p || typeof p !== 'object' || !p.notReady) continue;
        const code = String(p.pillar || 'pillar') + '-not-ready';
        const reason = String(p.reason || '');
        const optionalIdleRuntime = /not attached|not initiali[sz]ed|deferred|lazy/i.test(reason)
            && !p.error
            && !p.fatal
            && !p.severity;
        if (optionalIdleRuntime) {
            clearSystemError(code);
            continue;
        }
        pushSystemError(code, reason || 'runtime not attached', undefined);
    }
}
