// ============ API CLIENT — talks to the real backend ============
//
// Everything the UI used to read from the fixtures in data.jsx now comes from
// here. The shape returned by mapDevice() is deliberately identical to the old
// fixture shape, so the presentation components did not have to change.
//
// Loaded before every other jsx file, so window.STApi exists by the time any
// component runs.

const ST_API_BASE = "/api";
const TOKEN_KEY = "st-access-token";
const REFRESH_KEY = "st-refresh-token";

let _token = localStorage.getItem(TOKEN_KEY) || null;
let _refresh = localStorage.getItem(REFRESH_KEY) || null;

function setTokens(access, refresh) {
  _token = access || null;
  _refresh = refresh || _refresh;
  if (_token) localStorage.setItem(TOKEN_KEY, _token); else localStorage.removeItem(TOKEN_KEY);
  if (_refresh) localStorage.setItem(REFRESH_KEY, _refresh);
}

function clearTokens() {
  _token = null; _refresh = null;
  localStorage.removeItem(TOKEN_KEY);
  localStorage.removeItem(REFRESH_KEY);
}

// The firmware answers in English; the backend and the UI are French. Rather
// than translate on the device (where every byte is flash), map the handful of
// strings a user can actually provoke.
const DEVICE_ERRORS = [
  [/^no signal( captured yet)?$/i, "Aucun signal reçu"],
  [/^no such button$/i,            "Bouton introuvable"],
  [/^no button named/i,            "Bouton introuvable"],
  [/^empty slot$/i,                "Bouton vide"],
  [/^no usable frame$/i,           "Trame inexploitable"],
  [/^flash write failed$/i,        "Écriture mémoire échouée"],
  [/^frame read failed$/i,         "Lecture de la trame échouée"],
  [/^device busy, retry$/i,        "Appareil occupé, réessayez"],
  [/^device low on memory$/i,      "Mémoire insuffisante sur l'appareil"],
  [/^no free slot/i,               "Plus d'emplacement libre"],
  [/^name required$/i,             "Nom requis"],
  [/^no change$/i,                 "Déjà dans cet état"],
  [/^nothing to change/i,          "Rien à modifier"],
  [/^command has no action$/i,     "Commande vide"],
  [/^transmitter unavailable$/i,   "Émetteur indisponible"],
  [/^temperature out of range/i,   "Température hors plage (16–30 °C)"],
  [/^no "?(\w+)"? or "?power"? key learned/i, "Touche marche/arrêt non apprise"],
  [/^key "?([^"]+)"? not learned/i, "Touche « $1 » non apprise"],
];

function localise(msg) {
  if (!msg) return msg;
  for (const [re, fr] of DEVICE_ERRORS) {
    const m = String(msg).match(re);
    if (m) return fr.replace("$1", m[1] ?? "");
  }
  return msg;
}

// Thrown with the backend's own message so the UI can show why something was
// refused ("Température hors plage", "Appareil hors ligne", …) instead of a
// generic failure.
class ApiError extends Error {
  constructor(message, status) { super(localise(message)); this.status = status; }
}

async function request(path, { method = "GET", body, retry = true } = {}) {
  const headers = { "Content-Type": "application/json" };
  if (_token) headers.Authorization = "Bearer " + _token;

  const res = await fetch(ST_API_BASE + path, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  // One transparent refresh attempt, then give up and force a re-login.
  if (res.status === 401 && retry && _refresh) {
    const ok = await refreshTokens();
    if (ok) return request(path, { method, body, retry: false });
    clearTokens();
  }

  const text = await res.text();
  let data = null;
  try { data = text ? JSON.parse(text) : null; } catch { data = { message: text }; }

  if (!res.ok) {
    const msg = (data && (data.message || data.error)) || ("Erreur " + res.status);
    throw new ApiError(Array.isArray(msg) ? msg.join(", ") : msg, res.status);
  }
  return data;
}

// The CSV export comes back as a file rather than JSON, and still needs the
// token, so it cannot be a plain link.
async function requestText(path, { retry = true } = {}) {
  const headers = {};
  if (_token) headers.Authorization = "Bearer " + _token;
  const res = await fetch(ST_API_BASE + path, { headers });
  if (res.status === 401 && retry && _refresh) {
    const ok = await refreshTokens();
    if (ok) return requestText(path, { retry: false });
    clearTokens();
  }
  const text = await res.text();
  if (!res.ok) throw new ApiError(localise("Chargement impossible"), res.status);
  return text;
}

async function refreshTokens() {
  try {
    const res = await fetch(ST_API_BASE + "/auth/refresh", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refreshToken: _refresh }),
    });
    if (!res.ok) return false;
    const d = await res.json();
    setTokens(d.accessToken || d.access_token, d.refreshToken || d.refresh_token);
    return true;
  } catch { return false; }
}

// ---- mapping -------------------------------------------------------------

// The board reports no ambient temperature, humidity or power draw — it is an
// IR blaster, not a sensor. Those come back null and the UI renders "—"
// rather than inventing a number.
function mapDevice(d) {
  return {
    id: d.id,
    name: d.name,
    mqtt: d.mqttId,
    orgId: d.orgId,
    unit: d.unitId,
    // What sort of device this is, and for a meter how it is wired: the
    // sensor and energy pages are built on these.
    kind: d.kind || "ir",
    medium: d.medium || null,
    channels: d.channels || 1,
    channelLabels: d.channelLabels || {},
    linkedDeviceId: d.linkedDeviceId || null,
    linkedChannel: d.linkedChannel || null,
    // How a meter is wired, and whether the board said so itself.
    source: d.source || null,
    model: d.model || null,
    pulseRate: d.pulseRate != null ? d.pulseRate : null,
    describedAt: d.describedAt || null,
    comfortMin: d.comfortMin != null ? d.comfortMin : 19,
    comfortMax: d.comfortMax != null ? d.comfortMax : 26,
    subscribedKva: d.subscribedKva != null ? d.subscribedKva : null,
    online: !!d.online,
    power: !!d.power,
    target: d.target,
    mode: d.mode,
    fan: d.fan,
    temp: null,
    hum: null,
    watt: null,
    fw: d.fwVersion,
    ip: d.ip,
    signal: d.signalDbm,
    lastSeen: d.lastSeenAt,
  };
}

function mapUser(u) {
  const name = u.name || u.email;
  return {
    id: u.id,
    name,
    email: u.email,
    role: u.role === "manager" ? "Gestionnaire"
        : u.role === "superadmin" ? "Superadmin" : "Équipe terrain",
    rawRole: u.role,
    isAdmin: u.role === "manager" || u.role === "superadmin",
    orgId: u.orgId,
    initials: u.initials || name.split(/\s+/).map(s => s[0]).join("").slice(0, 2).toUpperCase(),
  };
}

// ---- public surface ------------------------------------------------------

window.STApi = {
  ApiError,
  hasSession: () => !!_token,

  async login(email, password) {
    const d = await request("/auth/login", { method: "POST", body: { email, password } });
    setTokens(d.accessToken || d.access_token, d.refreshToken || d.refresh_token);
    return mapUser(await request("/auth/me"));
  },

  async me() { return mapUser(await request("/auth/me")); },

  async logout() {
    try { await request("/auth/logout", { method: "POST", body: { refreshToken: _refresh } }); }
    catch { /* the session is going away regardless */ }
    clearTokens();
  },

  async devices() {
    const rows = await request("/devices");
    return (Array.isArray(rows) ? rows : rows.items || []).map(mapDevice);
  },

  async units(orgId) {
    if (!orgId) return [];
    try {
      const rows = await request(`/orgs/${orgId}/units`);
      return (Array.isArray(rows) ? rows : rows.items || []).map(u => ({
        id: u.id, name: u.name, building: u.buildingId, floor: u.floor,
        status: u.status || "—", guest: u.guest || null, checkout: u.checkout || "",
      }));
    } catch { return []; }
  },

  // ---- learned IR buttons (the device's own key set) ----------------------

  async buttons(deviceId) {
    return request(`/devices/${deviceId}/ir/buttons`);
  },

  // Which learned button drives each control on the remote card.
  async remoteMap(deviceId) {
    return request(`/devices/${deviceId}/ir/remote-map`);
  },
  async setRemoteMap(deviceId, map) {
    return request(`/devices/${deviceId}/ir/remote-map`, { method: "PUT", body: map });
  },

  // Copy the timings of every button to the server, and push them back.
  async backupButtons(deviceId) {
    return request(`/devices/${deviceId}/ir/buttons/backup`, { method: "POST", body: {} });
  },
  async restoreButtons(deviceId) {
    return request(`/devices/${deviceId}/ir/buttons/restore`, { method: "POST", body: {} });
  },

  async refreshButtons(deviceId) {
    return request(`/devices/${deviceId}/ir/buttons/refresh`, { method: "POST", body: {} });
  },

  // Blocks for as long as the capture window stays open (~30 s), then either
  // resolves with the captured frame or throws the device's own reason.
  async learnButton(deviceId, name) {
    return request(`/devices/${deviceId}/ir/buttons/learn`, { method: "POST", body: { name } });
  },

  async sendButton(deviceId, name) {
    return request(`/devices/${deviceId}/ir/buttons/send`, { method: "POST", body: { name } });
  },

  async renameButton(deviceId, name, newName) {
    return request(`/devices/${deviceId}/ir/buttons/rename`, { method: "POST", body: { name, newName } });
  },

  async deleteButton(deviceId, name) {
    return request(`/devices/${deviceId}/ir/buttons/${encodeURIComponent(name)}`, { method: "DELETE" });
  },

  // ---- administration: clients, their people, their devices ---------------

  async orgs()                { return request("/orgs"); },

  // The public demo: whether it is on, and the accounts the sign-in page offers.
  async demoInfo()                  { return request("/demo"); },

  // "Demandes & contact": requests sent from the public site (superadmin).
  async contactRequests()           { return request("/contact"); },
  async contactSummary()            { return request("/contact/summary"); },
  async updateContactRequest(id, p) { return request(`/contact/${id}`, { method: "PATCH", body: p }); },
  async deleteContactRequest(id)    { return request(`/contact/${id}`, { method: "DELETE" }); },
  async resendContactRequest(id)    { return request(`/contact/${id}/resend`, { method: "POST", body: {} }); },
  async createOrg(name)       { return request("/orgs", { method: "POST", body: { name } }); },
  async updateOrg(id, patch)  { return request(`/orgs/${id}`, { method: "PATCH", body: patch }); },
  async deleteOrg(id)         { return request(`/orgs/${id}`, { method: "DELETE" }); },

  async users(orgId) {
    return request("/users" + (orgId ? `?orgId=${orgId}` : ""));
  },
  // Returns the new account including its generated password, shown once.
  async createUser(body)      { return request("/auth/users", { method: "POST", body }); },
  async updateUser(id, patch) { return request(`/users/${id}`, { method: "PATCH", body: patch }); },
  async deleteUser(id)        { return request(`/users/${id}`, { method: "DELETE" }); },
  // Omit `password` to have one generated. Signs the account out everywhere.
  async resetPassword(id, password) {
    return request(`/users/${id}/reset-password`, { method: "POST", body: password ? { password } : {} });
  },

  async createUnit(orgId, name) {
    return request(`/orgs/${orgId}/units`, { method: "POST", body: { name } });
  },

  async renameUnit(orgId, id, name) {
    return request(`/orgs/${orgId}/units/${id}`, { method: "PATCH", body: { name } });
  },

  async deleteUnit(orgId, id) {
    return request(`/orgs/${orgId}/units/${id}`, { method: "DELETE" });
  },

  // Every room the signed-in account can see. The per-org call returns nothing
  // for a superadmin, who has no organisation of their own.
  async allUnits() {
    const rows = await request("/units");
    return (Array.isArray(rows) ? rows : []).map(u => ({
      id: u.id, orgId: u.orgId, name: u.name, building: u.buildingId, floor: u.floor,
    }));
  },

  // Provisioning also issues the device's MQTT credentials and seeds its IR
  // config, so always go through this rather than inserting a row.
  async createDevice(body)      { return request("/devices", { method: "POST", body }); },

  // Maintenance actions on one board.
  async rebootDevice(id)  { return request(`/devices/${id}/reboot`, { method: "POST", body: {} }); },
  async pingDevice(id)    { return request(`/devices/${id}/ping`, { method: "POST", body: {} }); },
  async activity(id)      { return request(`/devices/${id}/activity`); },
  // The room behind the device drawer: this device's own readings when it
  // has sensors, otherwise those of the sensors and meters in the same home.
  async roomTelemetry(id, hours = 24) { return request(`/devices/${id}/room?hours=${hours}`); },
  async cloneRemote(targetId, sourceId) {
    return request(`/devices/${targetId}/ir/clone-from/${sourceId}`, { method: "POST", body: {} });
  },
  async firmware()        { return request("/firmware"); },
  async createOtaJob(version, deviceIds) {
    return request("/ota/jobs", { method: "POST", body: { version, device_ids: deviceIds } });
  },
  async updateDevice(id, patch) { return request(`/devices/${id}`, { method: "PATCH", body: patch }); },
  async deleteDevice(id)        { return request(`/devices/${id}`, { method: "DELETE" }); },

  // ---- sensors and meters -------------------------------------------------
  // What every sensor and meter of this client reads right now: one request
  // for a whole page of cards.
  async latestMeasures(orgId)   { return request(`/devices/measures/latest${orgId ? `?org_id=${orgId}` : ""}`); },
  // One point per day over the retention window, per channel.
  async daily(id, days = 31)    { return request(`/devices/${id}/daily?days=${days}`); },
  async dailyCsv(id, days = 31) { return requestText(`/devices/${id}/export.csv?days=${days}`); },

  async schedules(orgId)       { return request("/schedules" + (orgId ? `?orgId=${orgId}` : "")); },
  async createSchedule(body)   { return request("/schedules", { method: "POST", body }); },
  async updateSchedule(id, p)  { return request(`/schedules/${id}`, { method: "PATCH", body: p }); },
  async deleteSchedule(id)     { return request(`/schedules/${id}`, { method: "DELETE" }); },

  // Resolves with the device's committed state, or throws with the reason the
  // device (or the backend) refused it.
  async command(deviceId, patch) {
    const d = await request(`/devices/${deviceId}/cmd`, { method: "POST", body: patch });
    return d.state ? mapDevice(d.state) : null;
  },
};
