/* Uleez — API client (JWT sessionStorage + silent refresh, fetch wrapper) */
(function () {
  const BASE = (window.KERYA_CONFIG && window.KERYA_CONFIG.API_BASE) || 'http://localhost:3000/api/v1';
  const ORIGIN = BASE.replace(/\/api\/v1\/?$/, '');
  /**
   * Traduction d'un message d'erreur réseau.
   *
   * Ces chaînes remontent en toasts dans l'espace loueur : les laisser en français annulait la
   * traduction de l'écran dès qu'une requête échouait. La garde sur `window.I18N` couvre le cas
   * où api.jsx serait chargé seul (scénario de secours), et le repli est le texte français —
   * jamais une clé nue à l'écran.
   */
  const tErr = (key, fr, params) => (window.I18N ? I18N.t(key, params) : fr);

  const TOKEN_KEY = 'kd_token';
  const REFRESH_KEY = 'kd_refresh';

  // sessionStorage (not localStorage): tokens are dropped when the tab closes, shrinking the
  // XSS exfiltration window. Silent refresh (see req/tryRefresh) keeps the session alive.
  const store = window.sessionStorage;
  const getToken = () => store.getItem(TOKEN_KEY);
  const setToken = (t) => store.setItem(TOKEN_KEY, t);
  const clearToken = () => store.removeItem(TOKEN_KEY);
  const getRefreshToken = () => store.getItem(REFRESH_KEY);
  const setRefreshToken = (t) => store.setItem(REFRESH_KEY, t);
  const clearRefreshToken = () => store.removeItem(REFRESH_KEY);

  // Our own user id, read from the access token's `sub`.
  //
  // Purely for RENDERING (deciding whether a reaction pill is "mine"). The payload is not
  // verified here and must never gate anything — the server re-derives identity from the
  // signed token on every request. Returns null rather than throwing on a malformed token.
  const currentUserId = () => {
    const t = getToken();
    if (!t) return null;
    try {
      const part = t.split('.')[1];
      if (!part) return null;
      const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
      const payload = JSON.parse(json);
      return payload && payload.sub ? String(payload.sub) : null;
    } catch (e) {
      return null;
    }
  };

  // Single in-flight refresh shared by concurrent 401s.
  //
  // Resolves to one of three OUTCOMES, and the distinction matters: only 'invalid' means the
  // session is really over. A refresh that fails because the network dropped, the server 500ed
  // or the throttler answered 429 says nothing about the credential — treating those as "signed
  // out" is what makes a session feel fragile. Callers keep the tokens on 'transient' and let
  // the user retry.
  const REFRESH_OK = 'ok', REFRESH_INVALID = 'invalid', REFRESH_TRANSIENT = 'transient';
  let _refreshInFlight = null;
  async function tryRefresh() {
    const rt = getRefreshToken();
    if (!rt) return REFRESH_INVALID;
    if (_refreshInFlight) return _refreshInFlight;
    _refreshInFlight = (async () => {
      try {
        const res = await fetch(`${BASE}/auth/refresh`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ refresh_token: rt }),
        });
        // The server explicitly rejected the credential (expired, rotated away, revoked).
        if (res.status === 401 || res.status === 403) return REFRESH_INVALID;
        if (!res.ok) return REFRESH_TRANSIENT;
        const data = await res.json();
        if (!data.access_token) return REFRESH_TRANSIENT;
        setToken(data.access_token);
        if (data.refresh_token) setRefreshToken(data.refresh_token);
        return REFRESH_OK;
      } catch (_) {
        // Network failure — the request never got an answer.
        return REFRESH_TRANSIENT;
      } finally {
        _refreshInFlight = null;
      }
    })();
    return _refreshInFlight;
  }

  const resolveUrl = (path) => {
    if (!path) return path;
    // Une photo fraîchement ajoutée (pas encore envoyée au serveur) est prévisualisée en
    // data: URL — la préfixer par ORIGIN comme un chemin relatif produisait une URL invalide
    // (`https://.../data:image/...`) et donc une icône d'image cassée dans le formulaire.
    if (/^(https?|data|blob):/.test(path)) return path;
    return `${ORIGIN}${path.startsWith('/') ? '' : '/'}${path}`;
  };

  /* ── Core fetch wrapper ──────────────────────────────────────────────────── */
  async function req(method, path, body, _retried) {
    const token = getToken();
    const headers = { 'Content-Type': 'application/json' };
    if (token) headers['Authorization'] = `Bearer ${token}`;

    const res = await fetch(`${BASE}${path}`, {
      method,
      headers,
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });

    if (res.status === 401) {
      // Access token likely expired — try one silent refresh then replay the request.
      const outcome = _retried ? REFRESH_INVALID : await tryRefresh();
      if (outcome === REFRESH_OK) return req(method, path, body, true);
      if (outcome === REFRESH_TRANSIENT) {
        // Keep the session: the refresh never got a verdict. Surfacing a normal error lets the
        // screen show "Réessayer" instead of throwing the user back to the login page.
        throw new Error(tErr('api.offline', 'Connexion au serveur impossible. Réessayez.'));
      }
      clearToken();
      clearRefreshToken();
      // Trigger global logout if App is mounted
      if (window.__kd_logout) window.__kd_logout();
      throw new Error(tErr('api.sessionExpired', 'Session expirée. Veuillez vous reconnecter.'));
    }

    if (!res.ok) {
      let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
      try { const d = await res.json(); if (typeof d.message === 'string') msg = d.message; } catch (_) {}
      const err = new Error(msg);
      // Le statut voyage avec l'erreur : un 409 « conversation déjà existante » se traite
      // (on relit le fil), il ne doit pas remonter comme un échec.
      err.status = res.status;
      throw err;
    }

    // 204 No Content
    if (res.status === 204) return null;
    return res.json();
  }

  const get    = (path)         => req('GET', path);
  const post   = (path, body)   => req('POST', path, body);
  const patch  = (path, body)   => req('PATCH', path, body);
  const put    = (path, body)   => req('PUT', path, body);
  const del    = (path)         => req('DELETE', path);

  // Download a binary file (xlsx/pdf) and trigger browser save
  async function downloadBlob(path, filename, _retried) {
    const token = getToken();
    const headers = {};
    if (token) headers['Authorization'] = `Bearer ${token}`;
    const res = await fetch(`${BASE}${path}`, { headers });
    if (res.status === 401 && !_retried && (await tryRefresh()) === REFRESH_OK) {
      return downloadBlob(path, filename, true);
    }
    if (!res.ok) {
      let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
      try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
      throw new Error(msg);
    }
    const blob = await res.blob();
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  // Multipart upload (auth + one silent refresh). Do NOT set Content-Type — the browser
  // sets the multipart boundary itself.
  async function uploadForm(path, formData, _retried) {
    const token = getToken();
    const headers = {};
    if (token) headers['Authorization'] = `Bearer ${token}`;
    const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData });
    if (res.status === 401 && !_retried && (await tryRefresh()) === REFRESH_OK) return uploadForm(path, formData, true);
    if (!res.ok) {
      let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
      try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
      throw new Error(msg);
    }
    return res.status === 204 ? null : res.json();
  }

  // Fetch a protected binary (attachment) with auth → Blob (for inline preview via object URL).
  async function authedBlob(path, _retried) {
    const token = getToken();
    const headers = {};
    if (token) headers['Authorization'] = `Bearer ${token}`;
    const res = await fetch(`${BASE}${path}`, { headers });
    if (res.status === 401 && !_retried && (await tryRefresh()) === REFRESH_OK) return authedBlob(path, true);
    if (!res.ok) throw new Error(`Erreur ${res.status}`);
    return res.blob();
  }

  /* ── Auth ────────────────────────────────────────────────────────────────── */

  // Ask the server which credential this account expects, then derive it.
  //
  // kdfVersion 1 means the raw password NEVER leaves this browser: we send
  // HKDF(PBKDF2(password), "uleez/auth/v1") instead, and keep the sibling branch
  // "uleez/escrow/v1" to unseal the end-to-end encryption private key. If the server saw
  // the password it could derive that second branch too, and the encryption guarantee
  // would be void — this is the whole reason the login shape changed.
  async function resolveCredential(phone, password) {
    if (!window.ULC || !ULC.available) return { credential: password, kdfVersion: 0 };
    let params = { kdfVersion: 0, iterations: ULC.KDF_ITERATIONS };
    try {
      const res = await fetch(`${BASE}/auth/kdf-params`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phone }),
      });
      if (res.ok) params = await res.json();
    } catch (_) {
      // Offline or old server: fall back to the legacy shape rather than blocking login.
    }
    if (params.kdfVersion !== 1) return { credential: password, kdfVersion: 0 };
    return {
      credential: await ULC.deriveAuthSecret(password, phone, params.iterations),
      kdfVersion: 1,
      iterations: params.iterations,
    };
  }

  const auth = {
    resolveCredential,
    login: async (phone, password) => {
      const { credential, kdfVersion } = await resolveCredential(phone, password);
      const res = await fetch(`${BASE}/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phone, password: credential }),
      });
      if (!res.ok) {
        let msg = tErr('api.badCredentials', 'Identifiants incorrects. Vérifiez votre numéro et mot de passe.');
        try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
        throw new Error(msg);
      }
      const data = await res.json();
      // Carry the raw password out of here ONLY in memory, so the caller can unseal the
      // E2E key and (for a legacy account) perform the one-off KDF upgrade. It is never
      // stored and never sent anywhere else.
      return { ...data, _e2e: { phone, password, kdfVersion } };
    },

    // One-off migration of a legacy account, run straight after a successful v0 login while
    // the plaintext password is still in hand. The server re-verifies it, so a stolen token
    // alone cannot trigger this.
    upgradeKdf: (currentPassword, authSecret) =>
      post('/auth/upgrade-kdf', { currentPassword, authSecret }),

    kdfParams: async (phone) => {
      const res = await fetch(`${BASE}/auth/kdf-params`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phone }),
      });
      if (!res.ok) throw new Error(`Erreur ${res.status}`);
      return res.json();
    },

    signup: (data) => post('/auth/signup', data),
    me: () => get('/auth/me'),
    logout: () => post('/auth/logout'),

    /**
     * Préférences du COMPTE (et non du loueur) : `preferredLanguage` vit sur User, pas sur
     * Lessor. Passer par `lessor.updateProfile` (PATCH /lessors/profile/me) échouerait en 400 —
     * son DTO ne whiteliste pas ce champ, et le ValidationPipe global est en
     * forbidNonWhitelisted.
     *
     * Ce champ n'est PAS cosmétique : c'est lui que le backend lit pour choisir la langue des
     * e-mails (facture de commission, relance, renouvellement d'abonnement).
     */
    updateAccount: (data) => patch('/users/me', data),

    // Authenticated password change. Answers with a FRESH token pair, because the server
    // revokes everything issued before the change — pass the result to API.setSession or the
    // very next request 401s.
    //
    // `phone` is needed because the new credential is derived from it (the KDF salt is
    // SHA-256("uleez.auth.v1|" + phone)); pass null to keep the legacy raw-password shape.
    changePassword: async (currentPassword, newPassword, phone) => {
      if (!phone || !window.ULC || !ULC.available) {
        return post('/auth/change-password', { currentPassword, newPassword });
      }
      const cur = await resolveCredential(phone, currentPassword);
      const next = await ULC.deriveAuthSecret(newPassword, phone, ULC.KDF_ITERATIONS);
      // Re-scellement de la clé E2E sous le NOUVEAU mot de passe, joint à la requête. Le web ne
      // le faisait JAMAIS : réparer le seul serveur aurait fait perdre l'historique chiffré à
      // chaque loueur changeant son mot de passe. Un échec ici (mauvais mot de passe courant)
      // interrompt tout avant le moindre changement.
      const reseal = (window.E2E && E2E.resealUnderPassword)
        ? await E2E.resealUnderPassword(phone, currentPassword, newPassword)
        : null;
      return post('/auth/change-password', {
        currentPassword: cur.credential,
        newPassword: next,
        kdfVersion: 1,
        ...(reseal || {}),
      });
    },

    // Password reset — all three are unauthenticated, so they bypass req() (which would
    // fire the global logout on a 401) and talk to BASE directly.
    forgotPassword: async (email) => {
      const res = await fetch(`${BASE}/auth/forgot-password`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });
      if (!res.ok) {
        let msg = tErr('api.resetSendFailed', 'Impossible d\'envoyer le lien. Réessayez dans quelques minutes.');
        try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
        throw new Error(msg);
      }
      return res.json();
    },
    // Returns false rather than throwing: a dead link is an expected outcome here.
    checkResetToken: async (token) => {
      // Renvoie l'OBJET complet (valid + matériel de clé E2E : hasEncryptedHistory,
      // canRestoreHistory, recoveryWrappedPrivateKey, recoverySalt, wrapSalt, kdfIterations),
      // dont la page de reset a besoin pour re-sceller ou faire tourner la clé. Avant on ne
      // gardait que `valid`, d'où une réinitialisation web qui laissait la messagerie cassée.
      try {
        const res = await fetch(`${BASE}/auth/reset-password/${encodeURIComponent(token)}/valid`);
        if (!res.ok) return { valid: false };
        const d = await res.json();
        return d && typeof d === 'object' ? d : { valid: false };
      } catch (_) { return { valid: false }; }
    },
    resetPassword: async (token, newPassword, reseal) => {
      const res = await fetch(`${BASE}/auth/reset-password`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        // `reseal` : re-scellement (avec code) OU rotation (sans code, `publicKeyJwk` présent).
        // Le serveur lit les champs présents ; absents, il réinitialise juste le mot de passe.
        body: JSON.stringify({ token, newPassword, ...(reseal || {}) }),
      });
      if (!res.ok) {
        let msg = tErr('api.resetLinkBad', 'Lien de réinitialisation invalide ou expiré');
        try { const d = await res.json(); msg = Array.isArray(d.message) ? d.message[0] : (d.message || msg); } catch (_) {}
        throw new Error(msg);
      }
      return res.json();
    },
  };

  /* ── Admin ───────────────────────────────────────────────────────────────── */
  const admin = {
    // `date` est l'ancre qui permet de consulter un mois PASSÉ. Sans elle, le tableau de bord
    // reste collé à la période en cours — ce qu'il était, faute d'accepter le paramètre.
    getDashboard: (period, date) => {
      const p = new URLSearchParams();
      if (period) p.set('period', period);
      if (date) p.set('date', date);
      const qs = p.toString();
      return get(`/admin/dashboard${qs ? `?${qs}` : ''}`);
    },

    // Lessors
    createLessor:    (data)        => post('/admin/lessors', data),
    getLessors:      (status) => status && status !== 'all'
      ? get(`/admin/lessors?status=${status}`)
      : get('/admin/lessors'),
    // `force` + motif : approuver un loueur sans abonnement en règle reste possible, mais
    // jamais par accident. Sans `force`, le serveur répond 409 SUBSCRIPTION_REQUIRED.
    approveLessor:   (id, opts)    => patch(`/admin/lessors/${id}/approve`, opts || {}),
    // « Pourquoi le bouton Approuver est-il verrouillé ? »
    getLessorCompliance: (id)      => get(`/admin/lessors/${id}/compliance`),
    rejectLessor:    (id, reason)  => patch(`/admin/lessors/${id}/reject`, { reason }),
    suspendLessor:   (id, reason)  => patch(`/admin/lessors/${id}/suspend`, reason ? { reason } : {}),
    reactivateLessor:(id)          => patch(`/admin/lessors/${id}/reactivate`),
    updateLessor:    (id, data)    => patch(`/admin/lessors/${id}`, data),
    // Photo d'agence, réglable par l'admin — jusqu'ici seul le loueur pouvait poser la sienne.
    uploadLessorLogo: async (id, file) => {
      const token = getToken();
      const fd = new FormData();
      fd.append('file', file);
      const res = await fetch(`${BASE}/admin/lessors/${id}/logo`, {
        method: 'POST',
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      });
      if (!res.ok) {
        let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
        try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
        throw new Error(msg);
      }
      return res.json();
    },
    deleteLessor:    (id)          => del(`/admin/lessors/${id}`),

    // Vehicles
    getVehicles:     (status) => status && status !== 'all'
      ? get(`/admin/vehicles?status=${status}`)
      : get('/admin/vehicles'),
    approveVehicle:  (id)          => patch(`/admin/vehicles/${id}/approve`),
    rejectVehicle:   (id, reason)  => patch(`/admin/vehicles/${id}/reject`, { reason }),
    // Retrait d'une annonce de la recherche, à tout moment. Distinct de `rejectVehicle` :
    // réversible sans repasser par une validation, et sans écraser le choix de publication
    // du loueur.
    suspendVehicle:  (id, reason)  => patch(`/admin/vehicles/${id}/suspend`, { reason }),
    unsuspendVehicle:(id)          => patch(`/admin/vehicles/${id}/unsuspend`),

    // Bookings
    getBookings: (status, page = 1, limit = 20, q = '') => {
      const params = new URLSearchParams({ page, limit });
      if (status && status !== 'all') params.set('status', status);
      // Server-side search: a reference outside the current page must still be findable.
      if (q) params.set('q', q);
      return get(`/admin/bookings?${params}`);
    },
    // Full dossier for one booking. `GET /bookings/:id` already returns everything an admin
    // needs (customer + lessor contact, payments, extensions, accounting) and audits the read.
    getBookingDetail: (id) => get(`/bookings/${id}`),
    getBookingReceipt: (id, ref) => downloadBlob(`/bookings/${id}/receipt`, `recu-${ref || id}.pdf`),
    // Cancel a cash payment recorded in error (mandatory written reason, audited server-side).
    voidPayment: (paymentId, reason) => post(`/payments/admin/${paymentId}/void`, { reason }),
    // Approve/reject a lessor's booking-deletion request.
    respondBookingDeletion: (id, approve, reason) =>
      patch(`/bookings/${id}/deletion-request`, { approve, ...(reason ? { reason } : {}) }),

    // Customers (mobile app users)
    getCustomers: (opts = {}) => {
      const params = new URLSearchParams();
      Object.entries(opts).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') params.set(k, v); });
      const qs = params.toString();
      // `avatarUrl` arrive en chemin relatif (`/uploads/…`) : on l'absolutise ICI, une fois, pour que
      // les écrans n'aient pas à refaire la résolution à la main (précédent : admin1, réservations).
      return get(`/admin/customers${qs ? `?${qs}` : ''}`).then((res) => {
        const withAvatar = (u) => (u && u.avatarUrl ? { ...u, avatarUrl: resolveUrl(u.avatarUrl) } : u);
        if (Array.isArray(res)) return res.map(withAvatar);
        return res && Array.isArray(res.items) ? { ...res, items: res.items.map(withAvatar) } : res;
      });
    },
    getCustomer:   (id)         => get(`/admin/customers/${id}`),
    // MARQUE le numéro comme vérifié ; ne le réécrit jamais (c'est le sel de la connexion du client).
    markPhoneVerified: (id)     => patch(`/admin/customers/${id}/phone-verified`),
    banCustomer:   (id, reason) => patch(`/admin/customers/${id}/ban`, reason ? { reason } : {}),
    unbanCustomer: (id)         => patch(`/admin/customers/${id}/unban`),
    reviewKyc:     (id, body)   => patch(`/admin/customers/${id}/kyc`, body),
    getTeam:       ()           => get('/admin/team'),
    createTeamMember: (body)    => post('/admin/team', body),
    removeTeamMember: (id)      => del(`/admin/team/${id}`),

    // Promo codes (campaign codes applied at checkout)
    getPromoCodes:   ()          => get('/admin/promo-codes'),
    createPromoCode: (body)      => post('/admin/promo-codes', body),
    updatePromoCode: (id, body)  => patch(`/admin/promo-codes/${id}`, body),
    deletePromoCode: (id)        => del(`/admin/promo-codes/${id}`),
    getPromoRedemptions: (id)    => get(`/admin/promo-codes/${id}/redemptions`),


    // Settings
    getSettings:    ()      => get('/admin/settings'),
    updateSettings: (data)  => put('/admin/settings', data),
    // État du canal SMS : disjoncteur, crédits, politique configurée ET politique effective.
    getSmsChannel:  ()      => get('/admin/settings/sms-channel'),
    // Candidatures loueur (formulaire public de la landing).
    getLessorApplications: (opts = {}) => {
      const q = new URLSearchParams();
      if (opts.status) q.set('status', opts.status);
      if (opts.page) q.set('page', opts.page);
      const s = q.toString();
      return get(`/admin/lessor-applications${s ? `?${s}` : ''}`);
    },
    updateLessorApplication: (id, data) => patch(`/admin/lessor-applications/${id}`, data),

    // Finance / activity
    getFinanceSummary: (period, date) => {
      const params = new URLSearchParams();
      if (period) params.set('period', period);
      if (date) params.set('date', date);
      const qs = params.toString();
      return get(`/admin/finance/summary${qs ? `?${qs}` : ''}`);
    },
    getActivity: (opts = {}) => {
      const params = new URLSearchParams();
      Object.entries(opts).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') params.set(k, v); });
      const qs = params.toString();
      return get(`/admin/activity${qs ? `?${qs}` : ''}`);
    },

    // Ce qu'ULEEZ perçoit : mois / trimestre / année en cours, les 3 sources de revenu
    // (commissions, frais clients, abonnements), avec la part déjà réglée et le reste à facturer.
    getRevenue: (date) => get(`/admin/finance/revenue${date ? `?date=${date}` : ''}`),
    // Suivi des abonnements : population des contrats, MRR, échéances, churn, encaissé réel.
    getSubscriptionSummary: (date) => get(`/admin/finance/subscriptions${date ? `?date=${date}` : ''}`),

    // Revenus tranche par tranche, avec leur ventilation par source. C'est ce qui rend
    // l'historique lisible : les trois tuiles disent « combien aujourd'hui », la série dit
    // « et les mois d'avant ». Elle est calculée par le MÊME code que les tuiles côté serveur,
    // donc les deux se réconcilient toujours.
    getRevenueSeries: ({ granularity = 'month', anchor, span = 12 } = {}) => {
      const p = new URLSearchParams({ granularity, span: String(span) });
      if (anchor) p.set('anchor', anchor);
      return get(`/admin/finance/revenue/series?${p.toString()}`);
    },

    exportLessorPayments: (lessorId, period = 'month', granularity = 'day', format = 'pdf') =>
      downloadBlob(`/payments/admin/lessors/${lessorId}/export?period=${period}&granularity=${granularity}&format=${format}`, `paiements-${period}.${format}`),
    exportLessorBookings: (lessorId, status, format = 'pdf') => downloadBlob(
      `/bookings/admin/lessors/${lessorId}/export?format=${format}${status && status !== 'all' ? `&status=${status}` : ''}`,
      `reservations.${format}`,
    ),

    getLessorInvoice: (lessorId, period, type = 'month') =>
      downloadBlob(`/admin/lessors/${lessorId}/invoice?period=${period}&type=${type}`, `facture-${period}.pdf`),
    sendLessorInvoice: (lessorId, period, type = 'month') =>
      post(`/admin/lessors/${lessorId}/invoice/send?period=${period}&type=${type}`),
    getLessorsMap: () => get('/admin/lessors/map'),
    getLessorDocuments: (lessorId) => get(`/documents/lessor/${lessorId}`),
    // Reviews left on this lessor's vehicles (public-safe shape: rating/comment/date/author).
    getLessorReviews: (lessorId) => get(`/reviews/lessor/${lessorId}`),

    /* ── Facturation loueurs : abonnements + créances ───────────────────────── */
    getBillingSummary: () => get('/admin/billing/summary'),
    getInvoices: (opts = {}) => {
      const q = new URLSearchParams();
      ['lessorId', 'kind', 'status', 'period', 'overdueOnly', 'page', 'limit', 'search']
        .forEach(k => { if (opts[k] != null && opts[k] !== '') q.set(k, opts[k]); });
      const s = q.toString();
      return get(`/admin/billing/invoices${s ? `?${s}` : ''}`);
    },
    getInvoice: (id) => get(`/admin/billing/invoices/${id}`),
    downloadInvoice: (id, reference) =>
      downloadBlob(`/admin/billing/invoices/${id}/pdf`, `${reference || 'facture'}.pdf`),
    // dryRun : l'e-mail part vers l'admin connecté, pour relire le document avant la vraie campagne.
    sendInvoice: (id, dryRun = false) =>
      post(`/admin/billing/invoices/${id}/send${dryRun ? '?dryRun=true' : ''}`),
    recordInvoicePayment: (id, body) => post(`/admin/billing/invoices/${id}/payment`, body),
    // Solde plusieurs créances d'un coup — indispensable pour réconcilier un historique
    // rattrapé, que vingt modales successives rendraient impraticable.
    bulkRecordInvoicePayment: (ids, body) =>
      post('/admin/billing/invoices/bulk-payment', { ids, ...body }),
    voidInvoice: (id, reason) => post(`/admin/billing/invoices/${id}/void`, { reason }),
    generateInvoices: (body) => post('/admin/billing/invoices/generate', body),

    getSubscriptions: (opts = {}) => {
      const q = new URLSearchParams();
      ['status', 'plan', 'expiringInDays'].forEach(k => { if (opts[k] != null && opts[k] !== '') q.set(k, opts[k]); });
      const s = q.toString();
      return get(`/admin/billing/subscriptions${s ? `?${s}` : ''}`);
    },
    createSubscription: (body) => post('/admin/billing/subscriptions', body),
    updateSubscription: (id, body) => patch(`/admin/billing/subscriptions/${id}`, body),
    // `body.markPaid` enregistre le règlement dans la foulée du renouvellement — le loueur
    // vient de payer au comptoir. Sans lui, la facture est émise et reste ouverte.
    renewSubscription: (id, body) => post(`/admin/billing/subscriptions/${id}/renew`, body || {}),
    cancelSubscription: (id, reason) => post(`/admin/billing/subscriptions/${id}/cancel`, { reason }),
    suspendSubscription: (id, reason) => post(`/admin/billing/subscriptions/${id}/suspend`, { reason }),
    reactivateSubscription: (id, opts = {}) => post(`/admin/billing/subscriptions/${id}/reactivate`, opts),
  };

  /* ── Lessor ──────────────────────────────────────────────────────────────── */
  const lessor = {
    /* Avis laissés sur MES véhicules (forme publique : note/commentaire/date/auteur/véhicule).
       Le même endpoint existe sous `admin` pour la fiche loueur du back-office ; il est public
       et non scopé, donc le dupliquer ici est sans risque — et évite qu'un écran loueur aille
       chercher sa donnée dans l'espace de noms `admin`. */
    getLessorReviews: (lessorId) => get(`/reviews/lessor/${lessorId}`),

    /* ── Mon abonnement & mes factures ────────────────────────────────────────
       LECTURE SEULE, et il n'y a rien d'autre à exposer : le loueur consulte et télécharge,
       il ne modifie ni son contrat ni ses créances. La portée est faite côté serveur à
       partir du JWT — aucun identifiant de loueur ne circule dans ces appels. */
    getBilling: () => get('/lessor/billing'),
    downloadInvoice: (id, reference) =>
      downloadBlob(`/lessor/billing/invoices/${id}/pdf`, `${reference || 'facture'}.pdf`),

    getProfile:    ()           => get('/lessors/profile/me'),
    createProfile: (data)       => post('/lessors/profile', data),
    updateProfile: (data)       => patch('/lessors/profile/me', data),
    requestActivation: ()       => patch('/lessors/profile/me/request-activation'),
    uploadDocument: async (lessorId, docType, file) => {
      const token = getToken();
      const fd = new FormData();
      fd.append('file', file);
      fd.append('ownerType', 'lessor');
      fd.append('ownerId', lessorId);
      fd.append('documentType', docType);
      const res = await fetch(`${BASE}/documents/upload`, {
        method: 'POST',
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      });
      if (!res.ok) {
        let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
        try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
        throw new Error(msg);
      }
      return res.json();
    },
    getMyDocuments: (lessorId) => get(`/documents/lessor/${lessorId}`),
    uploadLogo: async (file) => {
      const token = getToken();
      const fd = new FormData();
      fd.append('file', file);
      const res = await fetch(`${BASE}/lessors/profile/me/logo`, {
        method: 'POST',
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      });
      if (!res.ok) {
        let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
        try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
        throw new Error(msg);
      }
      return res.json();
    },

    getBookings:   (status)     => status && status !== 'all'
      ? get(`/bookings/lessor?status=${status}`)
      : get('/bookings/lessor'),
    getBooking:    (id)         => get(`/bookings/${id}`),
    updateBookingStatus: (id, status, reason) =>
      patch(`/bookings/${id}/status`, { status, ...(reason ? { reason } : {}) }),
    cancelBooking: (id)         => patch(`/bookings/${id}/cancel`),
    hideBooking:   (id)         => patch(`/bookings/${id}/hide`),
    // Deletion request (goes to admin for approval — anti-fraud, never hard-deletes).
    requestBookingDeletion: (id, reason) => post(`/bookings/${id}/deletion-request`, reason ? { reason } : {}),

    getVehicles:   ()           => get('/vehicles/lessor/my-vehicles'),
    getVehicle:    (id)         => get(`/vehicles/${id}`),
    createVehicle: (data)       => post('/vehicles', data),
    updateVehicle: (id, data)   => put(`/vehicles/${id}`, data),
    publishVehicle:   (id)      => patch(`/vehicles/${id}/publish`),
    unpublishVehicle: (id)      => patch(`/vehicles/${id}/unpublish`),
    // Archivage réversible — équivalent self-service d'une suppression, qu'aucun endpoint
    // n'offre réellement (l'historique de réservations doit survivre).
    archiveVehicle:   (id)      => patch(`/vehicles/${id}/archive`),
    unarchiveVehicle: (id)      => patch(`/vehicles/${id}/unarchive`),

    getAvailabilities: (vehicleId) => get(`/availabilities/vehicle/${vehicleId}`),
    blockDates:  (vehicleId, startAt, endAt, reason) =>
      post('/availabilities/block', { vehicleId, startAt, endAt, reason }),
    unblockDates: (id) => del(`/availabilities/${id}`),

    // `anchor` (YYYY-MM) déplace la fenêtre dans le passé — sans lui, les quatre périodes sont
    // toutes accrochées à aujourd'hui et l'historique est inaccessible.
    getPayments: (period = 'month', granularity = 'day', anchor = null) =>
      get(`/payments/lessor?period=${period}&granularity=${granularity}${anchor ? `&anchor=${anchor}` : ''}`),
    exportPayments: (period = 'month', granularity = 'day', format = 'xlsx', anchor = null) =>
      downloadBlob(
        `/payments/lessor/export?period=${period}&granularity=${granularity}&format=${format}${anchor ? `&anchor=${anchor}` : ''}`,
        `paiements-${anchor || period}.${format}`,
      ),
    exportBookings: (status, format = 'xlsx') => downloadBlob(
      `/bookings/lessor/export?format=${format}${status && status !== 'all' ? `&status=${status}` : ''}`,
      `reservations.${format}`,
    ),
    getBookingPayments: (bookingId) => get(`/payments/booking/${bookingId}`),
    // No amount: the server settles the booking's own total. See PaymentsService.onSitePayment.
    recordOnSitePayment: (bookingId) => post(`/payments/on-site/${bookingId}`, {}),
    // Annule un encaissement PRÉSUMÉ : la location n'a pas eu lieu. Motif obligatoire (≥ 10 car.).
    declareNotFulfilled: (bookingId, reason) => post(`/bookings/${bookingId}/not-fulfilled`, { reason }),

    uploadPhoto: async (vehicleId, file, isCover = false) => {
      const token = getToken();
      const fd = new FormData();
      fd.append('file', file);
      const res = await fetch(`${BASE}/vehicles/${vehicleId}/photos?isCover=${isCover}`, {
        method: 'POST',
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      });
      if (!res.ok) {
        let msg = tErr('api.httpError', `Erreur ${res.status}`, { code: res.status });
        try { const d = await res.json(); msg = d.message || msg; } catch (_) {}
        throw new Error(msg);
      }
      return res.json();
    },
    deletePhoto: (vehicleId, photoId) => del(`/vehicles/${vehicleId}/photos/${photoId}`),
    reorderPhotos: (vehicleId, photoIds) => patch(`/vehicles/${vehicleId}/photos/reorder`, { photoIds }),

    getBookingDocuments: (bookingId) => get(`/documents/booking/${bookingId}`),
    // Renter's KYC identity docs (CNI + permis) for a booking the lessor owns — for pickup checks.
    getBookingCustomerDocuments: (bookingId) => get(`/bookings/${bookingId}/customer-documents`),

    // Booking extension: the customer requests +N days (mobile); the lessor accepts/rejects here.
    respondExtension: (bookingId, extensionId, approve) =>
      patch(`/bookings/${bookingId}/extend/${extensionId}`, { approve }),
    // État des lieux (check-in / check-out) photos captured by the renter.
    getInspection: (bookingId, mode) =>
      get(`/bookings/${bookingId}/inspection${mode ? `?mode=${mode}` : ''}`),
  };

  /* ── Support / Tickets ───────────────────────────────────────────────────── */
  // Decrypt a whole thread in place. No-op when E2E is unavailable or the messages are
  // legacy plaintext, so every caller can use it unconditionally.
  const decryptThread = (ticketId, messages) =>
    (window.E2E ? E2E.decryptThread(ticketId, messages) : Promise.resolve(messages || []));

  const support = {
    getMyTickets: ()             => get('/support/my'),
    getLessorTickets: ()         => get('/support/lessor'),
    // Emergency threads only — backs the "Assistance client" tab. Server-scoped to this
    // lessor's own bookings; there is no parameter that could widen it.
    getLessorSosTickets: ()      => get('/support/lessor/sos'),
    // Staff desk (admin + support_agent) — minimized ticket queue + booking/lessor context.
    getStaffTickets: (status)    => get(`/support/staff/tickets${status ? `?status=${status}` : ''}`),
    getStaffContext: (id)        => get(`/support/staff/tickets/${id}/context`),
    // Full booking file for a dispute. The server only serves a booking that some ticket
    // references — there is no list endpoint, by design — and audits every read.
    getStaffBooking: (bookingId) => get(`/support/staff/bookings/${bookingId}`),
    // Inspection photo for a dispute — plain private storage, not E2E (unlike chat
    // attachments), so a straight authedBlob is enough: no conversation key involved.
    staffInspectionPhotoObjectUrl: (downloadPath) => authedBlob(downloadPath).then((b) => URL.createObjectURL(b)),
    resolveStaffTicket: (id)     => patch(`/support/staff/tickets/${id}/resolve`),
    // ── Encryption-aware wrappers ──────────────────────────────────────────
    // Screens call these exactly as before and never touch a key: reads come back with a
    // readable `body`, writes go out sealed. A thread with no key we can open still loads —
    // its messages carry `undecryptable: true` and the UI renders a locked bubble.
    getTicket: async (id) => {
      const t = await get(`/support/${id}`);
      return t && t.messages ? { ...t, messages: await decryptThread(id, t.messages) } : t;
    },
    getByBooking: async (bookingId) => {
      const t = await get(`/support/booking/${bookingId}`);
      return t && t.messages ? { ...t, messages: await decryptThread(t.id, t.messages) } : t;
    },
    getMessages: async (id) => decryptThread(id, await get(`/support/${id}/messages`)),

    /** Decrypt a single realtime `message:new` frame. Safe on plaintext messages. */
    decryptMessage: (ticketId, m) =>
      (window.E2E ? E2E.decryptIncoming(ticketId, m) : Promise.resolve(m)),

    sendMessage: async (id, body, replyToMessageId, extra) => {
      const payload = { text: body, ...(extra || {}) };
      const sealed = window.E2E ? await E2E.encryptOutgoing(id, payload) : null;
      const base = replyToMessageId ? { replyToMessageId } : {};
      if (sealed) {
        const sent = await post(`/support/${id}/messages`, {
          ...base, body: sealed.body, encIv: sealed.encIv, encVersion: 1,
        });
        // Echo back the plaintext we just sent, so the caller renders it without a round-trip.
        return { ...sent, body, location: payload.location || null };
      }
      // Plaintext path: still used for conversations opened before v0.7.4, and whenever a
      // participant has not yet enrolled an encryption key.
      return post(`/support/${id}/messages`, {
        ...base, body,
        ...(payload.location ? { latitude: payload.location.lat, longitude: payload.location.lng } : {}),
      });
    },
    // Toggle an emoji reaction; the server answers with the message's full summary.
    react: (messageId, emoji)    => post(`/support/messages/${messageId}/reactions`, { emoji }),
    // Must match ALLOWED_REACTIONS server-side — anything else is rejected with a 400.
    REACTIONS: ['👍', '❤️', '😂', '😮', '😢', '🙏'],
    // Apply a `message:reaction` broadcast to a message's local reaction list.
    //
    // The event carries counts only — no `mine`. It has to: one object is emitted to the
    // whole room, so a per-viewer flag would be wrong for everyone but the sender (which is
    // exactly the bug this replaces). Each client derives `mine` from `actorUserId`.
    mergeReactions: (previous, event) => {
      const prev = previous || [];
      const byMe = event.actorUserId && event.actorUserId === currentUserId();
      return (event.reactions || [])
        .filter((r) => r && r.emoji)
        .map((r) => {
          let mine = prev.some((p) => p.emoji === r.emoji && p.mine);
          if (byMe && r.emoji === event.emoji) mine = !!event.added;
          return { emoji: r.emoji, count: r.count || 0, mine };
        });
    },
    /**
     * Open a conversation, encrypted when every participant has a published key.
     *
     * `participantUserIds` must list everyone who will ever need to read the thread. The
     * key is sealed for each of them AND for the support desk in one shot, because nobody
     * will be online later to grant access retroactively.
     *
     * If any participant has no key yet, this silently falls back to a plaintext thread —
     * refusing to open the conversation would be worse for the user than an unencrypted
     * one, and the server records encVersion 0 honestly rather than pretending.
     */
    create: async (data) => {
      const ids = data.participantUserIds || [];
      const minted = (window.E2E && ids.length) ? await E2E.grantsForNewConversation(ids) : null;
      const { participantUserIds, ...body } = data;
      if (!minted) return post('/support', body);

      // The ticket id is minted HERE, not by the server. The AAD of every message is
      // "<ticketId>|1", so the opening message must be sealed against the id the thread
      // will actually have — otherwise it would authenticate against a placeholder and be
      // permanently undecryptable on every later read.
      const ticketId = crypto.randomUUID();
      await E2E.rememberConversationKey(ticketId, minted.rawKey);
      const sealed = await E2E.encryptOutgoing(ticketId, { text: data.message });
      const ticket = await post('/support', {
        ...body,
        id: ticketId,
        message: sealed.body,
        encIv: sealed.encIv,
        encVersion: 1,
        grants: minted.grants,
      });
      return ticket;
    },

    /**
     * Garantit UNE conversation pour cette réservation et la renvoie (déchiffrée).
     *
     * GET d'abord ; sinon création d'un fil VIDE (scellé pour le locataire, le loueur et le bureau
     * support) ; si le serveur répond 409 (l'autre bord vient de l'ouvrir), on relit l'existant.
     * Remplace la création « au premier message » qui ouvrait un SECOND fil dès que l'id local
     * était périmé — chacun écrivait alors dans le sien et les messages « disparaissaient ».
     */
    ensureBookingConversation: async (booking) => {
      const existing = await support.getByBooking(booking.id);
      if (existing && existing.id) return existing;
      try {
        await support.create({
          bookingId: booking.id, message: '', category: 'booking', skipOpeningMessage: true,
          participantUserIds: [booking.renter?.id, currentUserId()].filter(Boolean),
        });
      } catch (ex) {
        if (!(ex && ex.status === 409)) throw ex;
      }
      return support.getByBooking(booking.id);
    },

    // Attachments (image/PDF). Upload multipart; preview/download via authenticated blob.
    //
    // On an encrypted conversation the bytes are sealed BEFORE upload, so the server stores
    // ciphertext it cannot inspect. That is why the sender validates the file's magic bytes
    // here — the server can no longer do it, and the recipient re-checks after decrypting.
    sendAttachment: async (id, file, caption) => {
      const fd = new FormData();
      const bytes = new Uint8Array(await file.arrayBuffer());

      if (window.E2E) {
        const sniffed = E2E.sniffMime(bytes);
        const sealedBytes = await E2E.encryptAttachment(id, bytes);
        if (sealedBytes) {
          if (!sniffed) throw new Error(tErr('api.fileTypeDenied', 'Type de fichier non autorisé (image, PDF ou audio)'));
          fd.append('file', new Blob([sealedBytes], { type: 'application/octet-stream' }), 'blob');
          fd.append('encVersion', '1');
          fd.append('declaredMime', sniffed);
          // La légende est un contenu de message : elle doit être SCELLÉE comme le corps
          // d'un message texte. Elle partait en clair dans une ligne pourtant marquée
          // chiffrée — donc lisible par le serveur — puis se révélait indéchiffrable à la
          // lecture, d'où « message chiffré » sous une photo qui s'affichait très bien.
          // Le nom du fichier suit le même chemin : il porte souvent une information
          // personnelle (« permis-benali.jpg »).
          const sealedMsg = await E2E.encryptOutgoing(id, {
            text: caption || '',
            attachmentName: file.name,
          });
          if (sealedMsg) {
            fd.append('caption', sealedMsg.body);
            fd.append('encIv', sealedMsg.encIv);
          }
          return uploadForm(`/support/${id}/attachment`, fd);
        }
      }
      fd.append('file', file);
      if (caption) fd.append('caption', caption);
      return uploadForm(`/support/${id}/attachment`, fd);
    },

    /**
     * Send several photos as ONE message (an album). Images only.
     *
     * The server writes one row per photo sharing `groupId`, so each file keeps its own
     * sealed blob and its own download route — and the clients merge the rows back into a
     * single mosaic bubble (see groupAlbums in ui.jsx).
     *
     * The CLIENT mints the group id: it is needed before the request, and it is what lets a
     * failed album be retried into the same group. The server validates its shape and
     * refuses one already used by another conversation.
     */
    sendAlbum: async (id, files, caption) => {
      const list = Array.from(files || []).slice(0, 6); // matches the server's FilesInterceptor cap
      if (list.length === 0) return null;
      const fd = new FormData();
      fd.append('groupId', crypto.randomUUID());
      let encrypted = false;

      for (let i = 0; i < list.length; i++) {
        const file = list[i];
        const bytes = new Uint8Array(await file.arrayBuffer());
        const sniffed = window.E2E ? E2E.sniffMime(bytes) : null;
        const sealedBytes = window.E2E ? await E2E.encryptAttachment(id, bytes) : null;
        if (sealedBytes) {
          // Validated HERE because the server only ever sees ciphertext; the recipient
          // re-validates after decrypting.
          if (!sniffed || sniffed.indexOf('image/') !== 0) {
            throw new Error(tErr('api.albumPhotosOnly', 'Un album ne peut contenir que des photos'));
          }
          encrypted = true;
          // albumIndex/albumCount ride inside the sealed payload so the order and size are
          // covered by the AAD.
          const sealedMsg = await E2E.encryptOutgoing(id, {
            text: i === 0 ? (caption || '') : '',
            attachmentName: file.name,
            albumIndex: i,
            albumCount: list.length,
          });
          fd.append('files', new Blob([sealedBytes], { type: 'application/octet-stream' }), 'blob');
          fd.append('captions', (sealedMsg && sealedMsg.body) || '');
          // Aligné sur `files` et `captions` — le serveur apparie l'indice i au fichier i.
          fd.append('encIvs', (sealedMsg && sealedMsg.encIv) || '');
          fd.append('declaredMimes', sniffed);
        } else {
          fd.append('files', file);
          fd.append('captions', i === 0 ? (caption || '') : '');
          fd.append('declaredMimes', file.type || '');
        }
      }
      if (encrypted) fd.append('encVersion', '1');
      return uploadForm(`/support/${id}/attachments`, fd);
    },
    /**
     * Fetch an attachment and hand back a usable Blob.
     *
     * On an encrypted conversation the server returns ciphertext as
     * `application/octet-stream`, so it is decrypted here and — crucially — its real type
     * is re-derived from its MAGIC BYTES, never from anything the sender declared. That
     * check is the compensating control for the server no longer being able to validate
     * the file itself: a payload that is not a genuine image/PDF/audio is refused before
     * any object URL exists for it.
     */
    attachmentBlob: async (downloadPath, ticketId) => {
      const raw = await authedBlob(downloadPath);
      if (!window.E2E || !ticketId) return raw;
      const key = await E2E.conversationKey(ticketId);
      if (!key) return raw; // plaintext conversation
      const clear = await E2E.decryptAttachment(ticketId, await raw.arrayBuffer());
      const mime = E2E.sniffMime(clear);
      if (!mime) throw new Error(tErr('api.attachmentDenied', 'Pièce jointe refusée : contenu non reconnu'));
      return new Blob([clear], { type: mime });
    },
    attachmentObjectUrl: (downloadPath, ticketId) =>
      support.attachmentBlob(downloadPath, ticketId).then(b => URL.createObjectURL(b)),
    downloadAttachment: async (downloadPath, filename, ticketId) => {
      const blob = await support.attachmentBlob(downloadPath, ticketId);
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = filename || 'fichier';
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      URL.revokeObjectURL(url);
    },
  };

  /* ── End-to-end encryption key directory ─────────────────────────────────── */
  // The server only ever stores and returns opaque blobs here: it never generates a key,
  // never unseals one, and never sees a passphrase.
  const keys = {
    mine:            ()        => get('/keys/me'),
    publish:         (body)    => put('/keys/me', body),
    publicOf:        (userId)  => get(`/keys/${userId}/public`),
    supportDeskPublic:  ()     => get('/keys/support-desk/public'),
    supportDeskWrapped: ()     => get('/keys/support-desk/wrapped'),
    enrolSupportDesk:   (body) => post('/keys/support-desk', body),
    forConversation:    (id)   => get(`/keys/conversation/${id}`),
  };

  /* ── Identity documents ──────────────────────────────────────────────────── */
  // KYC files are encrypted at rest and are NOT reachable from the public /uploads prefix
  // any more (they used to be, with no authentication at all). The only way in is this
  // authenticated endpoint, which decrypts server-side after an ownership check.
  const docs = {
    contentPath: (doc) => (doc && (doc.contentPath || (doc.id ? `/documents/${doc.id}/content` : null))) || null,
    // Blob + object URL for inline preview. Callers MUST revokeObjectURL on unmount.
    objectUrl: (doc) => {
      const path = docs.contentPath(doc);
      if (!path) return Promise.reject(new Error('Document indisponible'));
      return authedBlob(path).then(b => URL.createObjectURL(b));
    },
    download: (doc, filename) => {
      const path = docs.contentPath(doc);
      if (!path) return Promise.reject(new Error('Document indisponible'));
      return downloadBlob(path, filename || 'document');
    },
  };

  /* ── Document type labels ────────────────────────────────────────────────── */
  const DOCUMENT_TYPES = [
    { value: 'driving_license', label: 'Permis de conduire' },
    { value: 'id_card',         label: "Carte d'identité" },
    { value: 'passport',        label: 'Passeport' },
    { value: 'rc',              label: 'Registre de commerce' },
    { value: 'insurance',       label: 'Assurance' },
  ];

  /* ── Status mapping helpers ──────────────────────────────────────────────── */
  // Backend booking status → frontend display status
  const mapBookingStatus = (backendStatus) => {
    const map = {
      pending:          'pending',
      awaiting_payment: 'confirmed',  // show as confirmed + payment badge
      confirmed:        'confirmed',
      cancelled:        'cancelled',
      completed:        'completed',
      rejected:         'rejected',
      in_progress:      'in_progress',
    };
    return map[backendStatus] || backendStatus;
  };

  // Backend payment status → frontend display
  const mapPaymentStatus = (backendStatus) => {
    const map = {
      not_paid:       'unpaid',
      partially_paid: 'partial',
      paid:           'paid',
      refunded:       'refunded',
    };
    return map[backendStatus] || backendStatus;
  };

  // Backend vehicle status → frontend display
  const mapVehicleStatus = (v) => {
    // L'archivage est orthogonal à la modération/publication (voir `Vehicle.archivedAt`) mais
    // prime toujours à l'affichage : un véhicule archivé est invisible partout, quel que soit
    // son statut par ailleurs.
    if (v.archivedAt) return 'archived';
    if (v.status === 'under_review') return 'under_review';
    if (v.status === 'rejected') return 'rejected';
    if (v.published) return 'published';
    return 'unpublished';
  };

  // Normalize a booking from backend to frontend shape
  const normalizeBooking = (b) => ({
    id:       b.id,
    ref:      b.referenceCode,
    renter:   {
      // Needed to seal a conversation key for this participant — an encrypted thread has
      // to be addressed to a user id, not a display name.
      id:       b.customerId || b.customer?.id || null,
      name:     b.customer ? `${b.customer.firstName} ${b.customer.lastName}` : 'Locataire',
      initials: b.customer ? `${b.customer.firstName?.[0] || ''}${b.customer.lastName?.[0] || ''}`.toUpperCase() : '?',
      color:    KD.avColor(b.customer?.firstName || ''),
      // Profile photo, absolutised here: the API returns a relative /uploads path.
      avatarUrl: b.customer?.avatarUrl ? resolveUrl(b.customer.avatarUrl) : null,
      phone:    b.customer?.phone || null,
      // Information, pas un blocage : `false` = numéro jamais prouvé (SMS ou admin). `null` = inconnu.
      phoneVerified: b.customer?.phoneVerified ?? (b.customer && b.customer.phoneVerifiedAt !== undefined ? !!b.customer.phoneVerifiedAt : null),
      // Admin-only in practice: `safeCustomerForLessor` strips these for a lessor, so they are
      // simply absent rather than hidden client-side.
      email:    b.customer?.email || null,
      kycStatus: b.customer?.kycStatus || null,
      dateOfBirth: b.customer?.dateOfBirth || null,
    },
    vehicleId: b.vehicleId,
    vehicleName: b.vehicleName || (b.vehicle ? `${b.vehicle.brand} ${b.vehicle.model}` : '—'),
    vehicle:   b.vehicle || null,
    lessor:    b.lessor || null,
    pickupLocation: b.pickupLocation,
    returnLocation: b.returnLocation,
    // Logistics coordinates (wave 4 map). Fall back to the vehicle's pickup point.
    pickupLat: b.pickupLat != null ? Number(b.pickupLat) : (b.vehicle?.pickupLat != null ? Number(b.vehicle.pickupLat) : null),
    pickupLng: b.pickupLng != null ? Number(b.pickupLng) : (b.vehicle?.pickupLng != null ? Number(b.vehicle.pickupLng) : null),
    returnLat: b.returnLat != null ? Number(b.returnLat) : null,
    returnLng: b.returnLng != null ? Number(b.returnLng) : null,
    start:    b.pickupAt ? new Date(b.pickupAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) : '',
    end:      b.returnAt ? new Date(b.returnAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) : '',
    pickupAt: b.pickupAt,
    returnAt: b.returnAt,
    days:     b.rentalDays,
    amount:   parseFloat(b.totalAmount),
    subtotal: parseFloat(b.subtotalAmount),
    deposit:  parseFloat(b.depositAmount),
    extra:    parseFloat(b.extraAmount || 0),
    // Service fee (5%) charged to the CLIENT on top of the rental — collected by the lessor,
    // reversed to Kerya. Never part of the commission base nor of the lessor's net.
    serviceFee: parseFloat(b.serviceFee || 0),
    // Promo/voucher discount applied server-side.
    discount: parseFloat(b.discountAmount || 0),
    promoCode: b.appliedCode || null,
    // Server-computed payout breakdown (GET /bookings/:id only). Authoritative: the UI
    // renders these numbers as-is instead of recomputing commissions client-side, which
    // is how the web, the invoice PDF and the revenue screen used to drift apart.
    accounting: b.accounting || null,
    currency: b.currency || 'DZD',
    status:   mapBookingStatus(b.status),
    rawStatus: b.status,
    // Location annulée/refusée/supprimée et rien d'encaissé : « Rien à régler » (gris) au lieu
    // d'un « Impayé » rouge qui laissait croire à une dette. `rawPaymentStatus` reste la valeur
    // serveur brute, seule à piloter la logique (demande de suppression, etc.).
    payment:  (['cancelled', 'rejected', 'deleted'].includes(b.status) && (b.paymentStatus || 'not_paid') === 'not_paid')
      ? 'none'
      : mapPaymentStatus(b.paymentStatus),
    rawPaymentStatus: b.paymentStatus,
    awaitingPayment: b.status === 'awaiting_payment',
    // How the rental settles ('on_delivery' = cash at handover, the only V1 rail) and whether
    // the lessor had to accept it manually. Both matter when a party disputes what was agreed.
    paymentOption: b.paymentOption || null,
    approvalMode: b.approvalMode || null,
    expiresAt: b.expiresAt || null,
    extraDriver: !!b.extraDriver,
    discountFundedBy: b.discountFundedBy || null,
    // Recorded transactions (GET /bookings/:id only) — the money trail behind `payment`.
    payments: Array.isArray(b.payments) ? b.payments : [],
    notes:    b.notes,
    createdAt: b.createdAt,
    // ── Règlement : la date à laquelle l'argent a réellement changé de main ──
    // C'est elle qui décide du mois auquel la location est comptabilisée, pas `createdAt`.
    settledAt: b.settledAt || null,
    // Encaissement inféré par le système (48 h après la fin), non confirmé par le loueur.
    paymentPresumed: !!b.paymentPresumed,
    // Décidé par le SERVEUR (présumé + dans les 30 jours) : le front ne re-dérive pas la règle.
    canDeclareNotFulfilled: !!b.canDeclareNotFulfilled,
    createdMin: b.createdAt
      ? Math.round((Date.now() - new Date(b.createdAt)) / 60000)
      : 0,
    commissionRate: b.commissionRate != null ? Number(b.commissionRate) : null,
    isWelcomePeriod: !!b.isWelcomePeriod,
    // Extension requests (customer asks for extra days; lessor approves/rejects).
    extensions: Array.isArray(b.extensions) ? b.extensions : [],
    pendingExtension: b.pendingExtension || null,
    // Soft-deletion workflow.
    deletionReason: b.deletionReason || b.deletion_reason || null,
    deletionRequestedAt: b.deletionRequestedAt || b.deletion_requested_at || null,
  });

  const normalizeLessor = (l) => ({
    id:     l.id,
    name:   l.businessName || l.owner?.firstName || 'Loueur',
    email:  l.email || l.owner?.email || '',
    type:   l.type === 'agency' ? 'Agence' : 'Particulier',
    wilaya: l.wilaya || '',
    status: l.status,
    date:   l.createdAt ? new Date(l.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }) : '',
    fleet:  0,
    rating: Number(l.ratingAverage) || 0,
    phone:  l.phone || l.owner?.phone || '',
    commissionRate: l.commissionRate != null ? Number(l.commissionRate) : null,
    welcomePeriodEndsAt: l.welcomePeriodEndsAt || null,
    rib: l.rib || '',
    address: l.address || '',
    city: l.city || '',
    legalIdentifier: l.legalIdentifier || '',
    taxIdentifier: l.taxIdentifier || '',
    // Chemin relatif (`/uploads/...`) : absolutisé ici une fois, comme les autres avatars.
    logoUrl: l.logoUrl ? resolveUrl(l.logoUrl) : null,
  });

  const normalizeVehicle = (v) => ({
    id:          v.id,
    brand:       v.brand,
    model:       v.model,
    year:        v.year,
    cat:         v.category,
    city:        v.pickupCity || '',
    price:       parseFloat(v.dailyPriceBase),
    weeklyPrice: v.weeklyPriceBase ? parseFloat(v.weeklyPriceBase) : null,
    monthlyPrice:v.monthlyPriceBase ? parseFloat(v.monthlyPriceBase) : null,
    deposit:     v.depositAmount ? parseFloat(v.depositAmount) : 0,
    status:      mapVehicleStatus(v),
    rating:      v.ratingAverage || 0,
    reviews:     v.reviewCount || 0,
    seats:       v.seats,
    fuel:        v.fuelType,
    // Le formulaire loueur enregistre littéralement 'Auto'/'Manuelle' (VEHICLE_API_FIELDS,
    // lessor3.jsx) — comparer à 'automatic' (jamais produit par ce formulaire) faisait
    // toujours retomber sur 'Manuelle', quelle que soit la transmission réellement choisie.
    trans:       String(v.transmission || '').toLowerCase().startsWith('auto') ? 'Auto' : 'Manuelle',
    plate:       v.registrationNumber || '',
    color:       v.color || null,
    hasAC:       v.airConditioning || false,
    mileage:     v.mileagePolicy || null,
    description: v.description || null,
    instantBooking: !v.requiresManualApproval,
    tint:        0,
    lessor:      v.lessor || null,
    lessorId:    v.lessorId,
    photos:      v.photos || [],
    published:   v.published,
    rawStatus:   v.status,
    // Suspension administrative : orthogonale au statut de modération, elle se compose avec
    // lui. `visible` est la seule réponse fiable à « ce véhicule est-il cherchable ? » — elle
    // dépend aussi du statut du LOUEUR, donc ne se lit sur aucune colonne isolée.
    adminSuspended: v.adminSuspended === true || v.adminSuspendedAt != null,
    adminSuspensionReason: v.adminSuspensionReason || null,
    visible:     v.visible,
    lessorStatus: v.lessorStatus || v.lessor?.status || null,
    createdAt:   v.createdAt || null,
    archivedAt:  v.archivedAt || null,
  });

  /* ── Notifications feed ──────────────────────────────────────────────── */
  const notifications = {
    list:        (page = 1, limit = 20) => get(`/notifications?page=${page}&limit=${limit}`),
    unreadCount: () => get('/notifications/unread-count'),
    markRead:    (id) => patch(`/notifications/${id}/read`),
    markAllRead: () => patch('/notifications/read-all'),
  };

  /* ── Realtime (socket.io) ────────────────────────────────────────────────
     Connects to <ORIGIN>/realtime with the current access token. Listeners are
     stored so they survive reconnects (e.g. after a silent token refresh). */
  const Realtime = (function () {
    let socket = null;
    let socketToken = null; // token the live socket was opened with
    let joinedTicket = null; // conversation to (re)join on every connect
    const listeners = {}; // event -> Set(callbacks)
    const reconnectHooks = new Set(); // run after every (re)connect — for resync

    function connect() {
      if (typeof io === 'undefined') return null;       // socket.io-client not loaded
      const token = getToken();
      if (!token) return null;
      // Keep a socket that is connected OR still mid-handshake.
      //
      // This used to be `if (socket && socket.connected) return socket;` followed by an
      // unconditional teardown — so any screen calling connect() while the handshake was
      // still in flight (which is exactly what happens at startup, when app.jsx and the
      // notification bell both connect) killed the pending socket and started over. Only
      // rebuild when there is no socket, or when the token actually changed.
      if (socket && socketToken === token && (socket.connected || socket.active)) return socket;
      if (socket) { socket.disconnect(); socket = null; }
      socket = io(`${ORIGIN}/realtime`, {
        auth: { token },
        reconnection: true,
        // NOT a finite count. With `reconnectionAttempts: 10` a laptop waking from sleep, a
        // tunnel, or a 2-minute Wi-Fi drop exhausted the budget and the socket stayed dead
        // for the rest of the page's life — which is why the notification bell only updated
        // after a manual reload. Retry forever, with a bounded backoff.
        reconnectionAttempts: Infinity,
        reconnectionDelay: 1000,
        reconnectionDelayMax: 15000,
        withCredentials: false,
      });
      socketToken = token;
      // (re)bind stored listeners onto the fresh socket
      Object.keys(listeners).forEach((ev) => {
        listeners[ev].forEach((cb) => socket.on(ev, cb));
      });
      // Rooms are per-connection server-side: after any (re)connect the socket is in NO room,
      // so re-join the active conversation — otherwise live messages + typing silently stop
      // after the first reconnect ("worked once, then never again").
      socket.on('connect', () => {
        if (joinedTicket) socket.emit('conversation:join', { ticketId: joinedTicket });
        // Events emitted while we were disconnected are GONE — the server does not replay
        // them. Anything showing live state (the bell) must re-read it from the API here.
        reconnectHooks.forEach((cb) => { try { cb(); } catch (_) {} });
      });
      // Server rejects an expired/blacklisted token → try one refresh + reconnect.
      socket.on('unauthorized', async () => {
        if ((await tryRefresh()) === REFRESH_OK) reconnect();
      });
      return socket;
    }

    // Browsers throttle timers and drop sockets in background tabs, and a sleeping machine
    // comes back with a dead connection and no event. Nudge on the two signals that actually
    // mean "the user is looking at this again" / "the network is back".
    if (typeof window !== 'undefined') {
      window.addEventListener('online', () => connect());
      document.addEventListener('visibilitychange', () => {
        if (!document.hidden) connect();
      });
    }

    /** Run `cb` after every (re)connect. Returns an unsubscribe function. */
    function onReconnect(cb) {
      reconnectHooks.add(cb);
      return () => reconnectHooks.delete(cb);
    }

    function reconnect() {
      if (socket) { socket.disconnect(); socket = null; }
      return connect();
    }

    function on(event, cb) {
      if (!listeners[event]) listeners[event] = new Set();
      listeners[event].add(cb);
      if (socket) socket.on(event, cb);
      return () => off(event, cb);
    }

    function off(event, cb) {
      if (listeners[event]) listeners[event].delete(cb);
      if (socket) socket.off(event, cb);
    }

    function emit(event, payload) { if (socket) socket.emit(event, payload); }
    function joinConversation(ticketId) { if (ticketId) { joinedTicket = ticketId; emit('conversation:join', { ticketId }); } }
    function leaveConversation(ticketId) { if (ticketId) { if (joinedTicket === ticketId) joinedTicket = null; emit('conversation:leave', { ticketId }); } }
    // Ephemeral typing signal — server-throttled, only accepted for a joined room.
    function sendTyping(ticketId, typing) { if (ticketId) emit('conversation:typing', { ticketId, typing: !!typing }); }
    function disconnect() { if (socket) { socket.disconnect(); socket = null; socketToken = null; } }

    return {
      connect, reconnect, on, off, emit, onReconnect,
      joinConversation, leaveConversation, sendTyping, disconnect,
      get connected() { return !!(socket && socket.connected); },
    };
  })();

  // Download a document (fetch as blob → save) instead of opening it in the browser.
  // Sends the auth token only to our own backend origin.
  async function downloadDocument(url, filename) {
    const target = resolveUrl(url);
    const token = getToken();
    const headers = {};
    if (token && target.indexOf(ORIGIN) === 0) headers['Authorization'] = `Bearer ${token}`;
    const res = await fetch(target, { headers });
    if (!res.ok) throw new Error(`Erreur ${res.status}`);
    const blob = await res.blob();
    const objectUrl = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = objectUrl;
    a.download = filename || 'document';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(objectUrl);
  }


  /* ── Téléphone : forme canonique E.164 ─────────────────────────────────────
     ⚠️ MIROIR EXACT de `backend/src/common/phone.util.ts` et de
     `kerya_dz/lib/core/constants/dial_codes.dart`. Les trois DOIVENT produire la même chaîne :
     depuis la V0.7.4, le sel de dérivation du mot de passe vaut
     SHA256("uleez.auth.v1|" + phone), donc une normalisation divergente entre l'inscription et
     la connexion rend le compte inaccessible, sans erreur exploitable. Toute modification de la
     table se fait dans les trois fichiers.

     `trunkZero` est faux pour l'Espagne, la Tunisie, l'Italie et l'Amérique du Nord, où le
     premier chiffre fait partie du numéro : y retirer un 0 le mutilerait. */
  const DIAL_CODES = [
    { iso: 'DZ', dial: '+213', flag: '🇩🇿', name: 'Algérie', lengths: [9], trunkZero: true, hint: '5 55 12 34 56' },
    { iso: 'FR', dial: '+33', flag: '🇫🇷', name: 'France', lengths: [9], trunkZero: true, hint: '6 12 34 56 78' },
    { iso: 'TN', dial: '+216', flag: '🇹🇳', name: 'Tunisie', lengths: [8], trunkZero: false, hint: '20 123 456' },
    { iso: 'MA', dial: '+212', flag: '🇲🇦', name: 'Maroc', lengths: [9], trunkZero: true, hint: '6 12 34 56 78' },
    { iso: 'ES', dial: '+34', flag: '🇪🇸', name: 'Espagne', lengths: [9], trunkZero: false, hint: '612 345 678' },
    { iso: 'IT', dial: '+39', flag: '🇮🇹', name: 'Italie', lengths: [9, 10], trunkZero: false, hint: '333 123 4567' },
    { iso: 'DE', dial: '+49', flag: '🇩🇪', name: 'Allemagne', lengths: [10, 11], trunkZero: true, hint: '151 12345678' },
    { iso: 'BE', dial: '+32', flag: '🇧🇪', name: 'Belgique', lengths: [9], trunkZero: true, hint: '470 12 34 56' },
    { iso: 'CH', dial: '+41', flag: '🇨🇭', name: 'Suisse', lengths: [9], trunkZero: true, hint: '79 123 45 67' },
    { iso: 'GB', dial: '+44', flag: '🇬🇧', name: 'Royaume-Uni', lengths: [10], trunkZero: true, hint: '7700 123456' },
    { iso: 'CA', dial: '+1', flag: '🇨🇦', name: 'Canada / États-Unis', lengths: [10], trunkZero: false, hint: '514 555 1234' },
  ];
  const DEFAULT_DIAL = '+213';

  /** Le pays d'un indicatif, avec repli sur l'Algérie plutôt qu'une exception. */
  function dialCodeFor(dial) {
    return DIAL_CODES.find((c) => c.dial === dial) || DIAL_CODES.find((c) => c.dial === DEFAULT_DIAL);
  }

  /** Numéro national saisi → E.164, ou null si la longueur ne convient pas au pays. */
  function toE164(dial, national) {
    const country = dialCodeFor(dial);
    let digits = String(national || '').replace(/\D/g, '');
    if (country.trunkZero && digits.startsWith('0')) digits = digits.slice(1);
    // Saisie recopiée d'un contact déjà international (« 213 6 12… » sous indicatif DZ). Le
    // contrôle de longueur protège les numéros nationaux commençant par les mêmes chiffres.
    const bare = country.dial.slice(1);
    if (digits.startsWith(bare) && country.lengths.includes(digits.length - bare.length)) {
      digits = digits.slice(bare.length);
    }
    return country.lengths.includes(digits.length) ? country.dial + digits : null;
  }

  const isValidNational = (dial, national) => toE164(dial, national) !== null;

  /** Longueur nationale maximale, pour borner la saisie. */
  const maxNationalLength = (dial) => Math.max.apply(null, dialCodeFor(dial).lengths);

  /**
   * Découpe un E.164 en { dial, national }, ou null.
   *
   * Indispensable au formulaire de profil : la puce « +213 » y était affichée À CÔTÉ d'une
   * valeur d'API qui contenait DÉJÀ « +213… », donnant un double préfixe à l'écran — et
   * réenregistrer persistait « +213+213… » en base.
   */
  function splitE164(e164) {
    const v = String(e164 || '').trim();
    if (!v.startsWith('+')) return null;
    const byLen = DIAL_CODES.slice().sort((a, b) => b.dial.length - a.dial.length);
    for (const c of byLen) {
      if (!v.startsWith(c.dial)) continue;
      const rest = v.slice(c.dial.length);
      if (c.lengths.includes(rest.length)) return { dial: c.dial, national: rest };
    }
    return null;
  }

  /* ── Signalements de bug envoyés depuis l'app mobile ──────────────────────
   * Les pièces jointes ne sont PAS chiffrées (contrairement aux pièces jointes de
   * conversation) : elles vivent en stockage privé et ne sortent que par une route staff.
   * D'où `authedBlob` direct, sans passer par E2E. */
  const bugReports = {
    list: (opts = {}) => {
      const q = new URLSearchParams();
      if (opts.status) q.set('status', opts.status);
      if (opts.kind) q.set('kind', opts.kind);
      if (opts.page) q.set('page', opts.page);
      const qs = q.toString();
      return get(`/bug-reports/staff/all${qs ? `?${qs}` : ''}`);
    },
    update: (id, data) => patch(`/bug-reports/staff/${id}`, data),
    objectUrl: (path) => authedBlob(path).then((b) => URL.createObjectURL(b)),
  };

  /* ── Promotions sur véhicule (prix barré, modérées) ───────────────────────
   * Loueur : crée/liste/retire ses promos. Admin : file de modération + approuver/refuser.
   * Client (mobile) consomme `/vehicle-promotions/live` directement, pas via ce back-office. */
  const vehiclePromotions = {
    mine:   ()        => get('/vehicle-promotions/mine'),
    create: (data)    => post('/vehicle-promotions', data),
    remove: (id)      => del(`/vehicle-promotions/${id}`),
    // Jours déjà loués sur une période, pour le dire AVANT l'envoi du formulaire. Le refus
    // du serveur (409 « déjà loué sur toute la période ») reste la source de vérité.
    availability: (vehicleId, from, to) => {
      const q = new URLSearchParams({ vehicleId, from, to });
      return get(`/vehicle-promotions/availability?${q.toString()}`);
    },
    staffList: (opts = {}) => {
      const q = new URLSearchParams();
      if (opts.status) q.set('status', opts.status);
      if (opts.page) q.set('page', opts.page);
      if (opts.limit) q.set('limit', opts.limit);
      const qs = q.toString();
      return get(`/vehicle-promotions/staff/all${qs ? `?${qs}` : ''}`);
    },
    staffBookings: (id) => get(`/vehicle-promotions/staff/${id}/bookings`),
    moderate: (id, data) => patch(`/vehicle-promotions/staff/${id}`, data),
  };

  window.API = {
    auth, admin, lessor, support, notifications, docs, keys, bugReports, vehiclePromotions, Realtime, downloadDocument,
    mapBookingStatus, mapPaymentStatus, mapVehicleStatus,
    normalizeBooking, normalizeLessor, normalizeVehicle,
    DOCUMENT_TYPES, resolveUrl,
    DIAL_CODES, DEFAULT_DIAL, dialCodeFor, toE164, isValidNational, maxNationalLength, splitE164,
    getToken, setToken, clearToken, currentUserId,
    getRefreshToken, setRefreshToken, clearRefreshToken,
    // Store both tokens after login; clear both on logout.
    setSession: (res) => {
      if (res && res.access_token) setToken(res.access_token);
      if (res && res.refresh_token) setRefreshToken(res.refresh_token);
      Realtime.reconnect();
    },
    clearSession: () => { clearToken(); clearRefreshToken(); Realtime.disconnect(); },
  };
})();
