export async function getJSON(url, { timeout = 10000 } = {}) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), timeout);   // never hang on a stuck request
  try {
    const r = await fetch(url, { signal: ctrl.signal });
    if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
    return await r.json();
  } finally {
    clearTimeout(t);
  }
}

export async function postJSON(url, body, opts) {
  return sendJSON('POST', url, body, opts);
}

export async function putJSON(url, body, opts) {
  return sendJSON('PUT', url, body, opts);
}

export async function delJSON(url, opts) {
  return sendJSON('DELETE', url, undefined, opts);
}

// timeout is opt-in (default: none) so normal calls are unchanged, but slow
// model-backed actions (e.g. deep preview) can bound the wait and fail calmly
// instead of leaving a button stuck if the LAN model hangs.
async function sendJSON(method, url, body, { timeout } = {}) {
  const opts = { method, credentials: 'same-origin' };
  if (body !== undefined) {
    opts.headers = { 'Content-Type': 'application/json' };
    opts.body = JSON.stringify(body);
  }
  let timer;
  if (timeout) {
    const ctrl = new AbortController();
    opts.signal = ctrl.signal;
    timer = setTimeout(() => ctrl.abort(), timeout);
  }
  try {
    const r = await fetch(url, opts);
    if (!r.ok) {
      let msg = r.statusText;
      try {
        const j = await r.json();
        if (j && j.detail) msg = j.detail;
      } catch {
        /* non-JSON error body */
      }
      throw new Error(msg);
    }
    return await r.json();
  } catch (e) {
    if (e?.name === 'AbortError') throw new Error('Timed out — the server took too long to respond.');
    throw e;
  } finally {
    clearTimeout(timer);
  }
}
