// InstantDevis — Firebase + Store + snapshot signé canonique + Workers (contrat)
// ─────────────────────────────────────────────────────────────────
// FONCTIONNEMENT :
//   • Sans config Firebase → localStorage uniquement (fonctionne immédiatement)
//   • Avec config Firebase  → localStorage + Firestore sync temps réel
//
// Pour activer Firebase :
//   1. Allez sur https://console.firebase.google.com
//   2. Créez un projet "InstantDevis"
//   3. Activez Firestore (mode test)
//   4. Copiez vos credentials dans AF_FIREBASE_CONFIG ci-dessous
// ─────────────────────────────────────────────────────────────────

const AF_FIREBASE_CONFIG = /*EDITMODE-BEGIN*/{
  apiKey:            "",   // ← Collez votre apiKey Firebase ici
  authDomain:        "",
  projectId:         "",
  storageBucket:     "",
  messagingSenderId: "",
  appId:             "",
}/*EDITMODE-END*/;

const _CANON = { brouillon: 1, envoye: 1, viewed: 1, signe: 1, paye: 1, retard: 1, locked: 1 };

const AF_DEVIS = {
  calcFromLignes(lignes) {
    const lines = Array.isArray(lignes) ? lignes : [];
    let totalHT = 0;
    const tvaByRate = {};
    for (const l of lines) {
      const qty = Number(l.qty != null ? l.qty : l.quantite) || 0;
      const pu = Number(l.pu != null ? l.pu : l.prixUnitaire) || 0;
      const rate = l.tva != null ? l.tva : (l.tauxTVA != null ? l.tauxTVA : 20);
      const ht = qty * pu;
      totalHT += ht;
      tvaByRate[rate] = (tvaByRate[rate] || 0) + ht * (Number(rate) / 100);
    }
    const totalTVA = Object.values(tvaByRate).reduce((a, b) => a + b, 0);
    const totalTTC = totalHT + totalTVA;
    const pct = (typeof AF_PROFILE_STORE !== 'undefined' ? (AF_PROFILE_STORE.get().acomptePercent ?? 30) : 30);
    const acompte = Math.round(totalTTC * (pct / 100) * 100) / 100;
    return { totalHT, totalTVA, totalTTC, tvaByRate, acompte };
  },

  canonicalStringify(value) {
    if (value === null || typeof value !== 'object') {
      return JSON.stringify(value);
    }
    if (Array.isArray(value)) {
      return '[' + value.map((v) => AF_DEVIS.canonicalStringify(v)).join(',') + ']';
    }
    const keys = Object.keys(value).sort();
    return '{' + keys.map((k) => JSON.stringify(k) + ':' + AF_DEVIS.canonicalStringify(value[k])).join(',') + '}';
  },

  async sha256HexOfString(str) {
    if (typeof crypto === 'undefined' || !crypto.subtle || !crypto.subtle.digest) {
      throw new Error('Web Crypto indisponible pour SHA-256');
    }
    const enc = new TextEncoder();
    const buf = enc.encode(str);
    const digest = await crypto.subtle.digest('SHA-256', buf);
    return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
  },

  normalizeLigneCanon(l) {
    const qty = Number(l.qty != null ? l.qty : l.quantite) || 0;
    const pu = Number(l.pu != null ? l.pu : l.prixUnitaire) || 0;
    const tva = l.tva != null ? Number(l.tva) : (l.tauxTVA != null ? Number(l.tauxTVA) : 20);
    const label = String(l.label || l.description || 'Ligne').trim();
    const unit = String(l.unit || l.unite || 'u').trim();
    return { label, qty, unit, pu, tva };
  },

  extractLignesArrayForSeal(devis) {
    const d = devis || {};
    const raw = Array.isArray(d.lignes) && d.lignes.length
      ? d.lignes
      : (Array.isArray(d.items) && d.items.length && typeof d.items[0] === 'object' ? d.items : []);
    if (raw.length) {
      return raw.map((l) => AF_DEVIS.normalizeLigneCanon(l));
    }
    const refAmt = (d.totalTTC != null ? Number(d.totalTTC) : Number(d.amount)) || 0;
    return [
      AF_DEVIS.normalizeLigneCanon({ label: 'Fourniture matériel', qty: 1, unit: 'forfait', pu: Math.round(refAmt * 0.4), tva: 10 }),
      AF_DEVIS.normalizeLigneCanon({ label: 'Main d\'œuvre', qty: Math.max(1, Math.round(refAmt / 110)), unit: 'h', pu: 55, tva: 20 }),
      AF_DEVIS.normalizeLigneCanon({ label: 'Déplacement & frais', qty: 1, unit: 'forfait', pu: Math.round(refAmt * 0.05), tva: 20 }),
    ];
  },

  buildArtisanLegalBlock(profile) {
    const p = profile || {};
    return {
      nom: String(p.nom || '').trim(),
      entreprise: String(p.entreprise || '').trim(),
      siret: String(p.siret || '').trim(),
      adresse: String(p.adresse || '').trim(),
      codePostal: String(p.codePostal || '').trim(),
      ville: String(p.ville || '').trim(),
      tel: String(p.tel || '').trim(),
      email: String(p.email || '').trim(),
      assureurNom: String(p.assureurNom || '').trim(),
      assuranceNum: String(p.assuranceNum || '').trim(),
      /** Contexte affichage / futur agent (non sensible) */
      companyPreset: String(p.companyPreset || '').trim(),
      legalForm: String(p.legalForm || '').trim(),
      vatMode: String(p.vatMode || '').trim(),
      primaryTrade: String(p.primaryTrade || '').trim(),
      customerMode: String(p.customerMode || '').trim(),
      fiscalMentionShort: String(p.fiscalMentionShort || '').trim(),
    };
  },

  /**
   * Instantané canonique du devis figé à la signature.
   * - Sans `seal` : aperçu v1 (lignes, totaux, client, cadre juridique artisan) — inchangé pour compatibilité.
   * - Avec `seal` : v2 = même contenu + date ISO, texte de consentement exact, signataire, empreinte de l’image de signature.
   * @param {{ signedAt: string, signerName: string, consentText: string, signatureImageHash: string }} [seal]
   */
  buildSignedSnapshot(devis, profile, seal) {
    const d = devis || {};
    const lignesCanon = AF_DEVIS.extractLignesArrayForSeal(d);
    const t = AF_DEVIS.calcFromLignes(lignesCanon);
    const clientBlock = {
      clientId: String(d.clientId || d.client || ''),
      clientName: String(d.clientName || '').trim(),
      clientAddress: String(d.clientAddress || '').trim(),
    };
    const base = {
      devisId: String(d.id || ''),
      title: String(d.title || '').trim(),
      trade: String(d.trade || 'multi'),
      client: clientBlock,
      lignes: lignesCanon,
      totals: {
        totalHT: Math.round(t.totalHT * 100) / 100,
        totalTVA: Math.round(t.totalTVA * 100) / 100,
        totalTTC: Math.round(t.totalTTC * 100) / 100,
        acompte: Math.round(t.acompte * 100) / 100,
      },
      legal: {
        mentions: `Devis BTP InstantDevis — acompte ${(profile && profile.acomptePercent != null) ? profile.acomptePercent : 30} % du TTC — TVA selon lignes — conditions affichées au client avant signature.`,
        artisan: AF_DEVIS.buildArtisanLegalBlock(profile),
      },
      createdAt: String(d.createdAt || ''),
    };
    if (seal && typeof seal === 'object' && seal.signedAt && seal.consentText != null) {
      const h = String(seal.signatureImageHash || '');
      if (!/^[a-f0-9]{64}$/i.test(h)) {
        throw new Error('InstantDevis: signatureImageHash (SHA-256 hex) requis pour le snapshot v2');
      }
      return {
        version: 2,
        ...base,
        signedAt: String(seal.signedAt),
        consentText: String(seal.consentText || ''),
        signatory: { name: String(seal.signerName != null ? seal.signerName : '').trim() },
        signature: { contentSha256: h.toLowerCase() },
      };
    }
    return {
      version: 1,
      ...base,
    };
  },

  /**
   * Charge utile hachée v1 (devis + consentement) — toujours supportée pour relecture des anciens scellés.
   */
  buildDocumentHashPayloadV1(devisId, consentText, signedSnapshot) {
    return {
      v: 1,
      devisId: String(devisId || ''),
      consentText: String(consentText || ''),
      snapshot: signedSnapshot,
    };
  },

  /**
   * Charge utile hachée v2 : snapshot canonique + consentement + signataire + date + empreinte de l’image (pas l’image elle-même).
   */
  buildDocumentHashPayloadV2(devisId, parts) {
    const p = parts || {};
    return {
      v: 2,
      consentText: String(p.consentText || ''),
      devisId: String(devisId || ''),
      signatureImageHash: String(p.signatureImageHash || '').toLowerCase(),
      signedAt: String(p.signedAt || ''),
      signerName: String(p.signerName != null ? p.signerName : '').trim(),
      snapshot: p.snapshot,
    };
  },

  /**
   * Devis scellé : hash SHA-256 + snapshot signé avec totaux (aligné preuve / affichage / PDF).
   */
  isDocumentSealed(d) {
    if (!d || !d.signedDocumentHash) return false;
    if (typeof d.signedDocumentHash !== 'string' || !/^[a-f0-9]{64}$/i.test(d.signedDocumentHash)) return false;
    const snap = d.signedSnapshot;
    if (!snap || typeof snap !== 'object' || !snap.totals) return false;
    const ver = Number(snap.version) || 1;
    if (ver < 1 || ver > 2) return false;
    if (ver === 2) {
      if (!snap.signedAt || !snap.consentText || !snap.signatory) return false;
      if (typeof snap.signature !== 'object' || !/^[a-f0-9]{64}$/i.test(String(snap.signature.contentSha256 || ''))) {
        return false;
      }
    }
    return true;
  },

  /**
   * Reconstruit l’empreinte documentaire attendue (sans comparer) pour un sceau appliqué côté client.
   */
  async expectedHashFromSignatureSeal(devisId, seal) {
    if (!seal || typeof seal !== 'object') {
      return { err: 'no_seal' };
    }
    const s = seal.signedSnapshot;
    if (!s || typeof s !== 'object') return { err: 'no_snapshot' };
    const ver = Number(s.version) || 1;
    if (ver === 1) {
      const pay = AF_DEVIS.buildDocumentHashPayloadV1(devisId, seal.consentText, s);
      const c = AF_DEVIS.canonicalStringify(pay);
      const h = await AF_DEVIS.sha256HexOfString(c);
      return { err: null, hash: h };
    }
    if (ver === 2) {
      if (!s.signature || !seal.signatureDataUrl) return { err: 'v2_incomplete' };
      const contentSha = await AF_DEVIS.sha256HexOfString(String(seal.signatureDataUrl));
      if (String(contentSha).toLowerCase() !== String(s.signature.contentSha256 || '').toLowerCase()) {
        return { err: 'image_hash_mismatch' };
      }
      const pay = AF_DEVIS.buildDocumentHashPayloadV2(devisId, {
        consentText: seal.consentText,
        signedAt: seal.signedAt,
        signerName: seal.signerName,
        signatureImageHash: contentSha,
        snapshot: s,
      });
      const c = AF_DEVIS.canonicalStringify(pay);
      const h = await AF_DEVIS.sha256HexOfString(c);
      return { err: null, hash: h };
    }
    return { err: 'unknown_snapshot_version' };
  },

  /**
   * Vérifie la cohérence du devis scellé (rehash des données stockées, comparaison à signedDocumentHash).
   */
  async verifySignedDocumentIntegrity(d) {
    if (!d || !d.signedDocumentHash) return { ok: false, reason: 'not_signed' };
    if (!d.signedSnapshot || !d.consentText || !d.signatureDataUrl) {
      return { ok: false, reason: 'incomplete' };
    }
    const r = await AF_DEVIS.expectedHashFromSignatureSeal(d.id, {
      signedSnapshot: d.signedSnapshot,
      consentText: d.consentText,
      signedAt: d.signedAt,
      signerName: d.signerName,
      signatureDataUrl: d.signatureDataUrl,
    });
    if (r.err) return { ok: false, reason: r.err };
    const same = String(r.hash).toLowerCase() === String(d.signedDocumentHash).toLowerCase();
    return same ? { ok: true } : { ok: false, reason: 'hash_mismatch' };
  },

  /**
   * Données d’affichage et d’export (vues, PDF, mail) : priorité au snapshot si scellé, sinon calcul / champs devis.
   */
  documentOutput(d) {
    const x = d || {};
    if (AF_DEVIS.isDocumentSealed(x)) {
      const snap = x.signedSnapshot;
      const t = snap.totals;
      const lignes = Array.isArray(snap.lignes) && snap.lignes.length
        ? snap.lignes.map((l) => ({
            label: String(l.label != null ? l.label : '').trim() || 'Ligne',
            qty: Number(l.qty != null ? l.qty : l.quantite) || 0,
            unit: String(l.unit != null ? l.unit : (l.unite || 'u')),
            pu: Number(l.pu != null ? l.pu : l.prixUnitaire) || 0,
            tva: l.tva != null ? Number(l.tva) : (l.tauxTVA != null ? Number(l.tauxTVA) : 20),
          }))
        : (Array.isArray(x.lignes) ? x.lignes : []);
      const c = snap.client || {};
      const cn = c.clientName != null && String(c.clientName).trim() !== '' ? String(c.clientName).trim() : String(x.clientName || '');
      const ca = c.clientAddress != null && String(c.clientAddress).trim() !== '' ? String(c.clientAddress).trim() : String(x.clientAddress || '');
      const tl = snap.title != null && String(snap.title).trim() !== '' ? String(snap.title).trim() : String(x.title || '');
      const signatoryName = (snap.signatory && snap.signatory.name)
        ? String(snap.signatory.name)
        : String(x.signerName || '');
      return {
        sealed: true,
        lignes,
        totalHT: Math.round(Number(t.totalHT) * 100) / 100,
        totalTVA: Math.round(Number(t.totalTVA) * 100) / 100,
        totalTTC: Math.round(Number(t.totalTTC) * 100) / 100,
        acompte: Math.round(Number(t.acompte) * 100) / 100,
        clientName: cn,
        clientAddress: ca,
        title: tl,
        sealedAt: String(snap.signedAt || x.signedAt || ''),
        sealedConsentText: String(snap.consentText || x.consentText || ''),
        signatoryName,
      };
    }
    const lignesRaw = Array.isArray(x.lignes) && x.lignes.length
      ? x.lignes
      : (Array.isArray(x.items) && x.items.length && typeof x.items[0] === 'object' ? x.items : []);
    const hasLines = lignesRaw && lignesRaw.length > 0;
    const linesCanon = hasLines ? lignesRaw.map((l) => AF_DEVIS.normalizeLigneCanon(l)) : [];
    const calc = hasLines ? AF_DEVIS.calcFromLignes(linesCanon) : null;
    const totalTTC = x.totalTTC != null
      ? Number(x.totalTTC)
      : (x.amount != null ? Number(x.amount) : (calc ? calc.totalTTC : 0));
    const totalHT = x.totalHT != null
      ? Number(x.totalHT)
      : (calc ? calc.totalHT : 0);
    const totalTVA = x.totalTVA != null
      ? Number(x.totalTVA)
      : (calc ? calc.totalTVA : 0);
    const _acomptePct = (typeof AF_PROFILE_STORE !== 'undefined' ? (AF_PROFILE_STORE.get().acomptePercent ?? 30) : 30);
    const acompte = x.acompte != null
      ? Number(x.acompte)
      : Math.round(totalTTC * (_acomptePct / 100) * 100) / 100;
    return {
      sealed: false,
      lignes: hasLines ? linesCanon : [],
      totalHT: Math.round(totalHT * 100) / 100,
      totalTVA: Math.round(totalTVA * 100) / 100,
      totalTTC: Math.round(totalTTC * 100) / 100,
      acompte: Math.round(acompte * 100) / 100,
      clientName: String(x.clientName || ''),
      clientAddress: String(x.clientAddress || ''),
      title: String(x.title || ''),
    };
  },
};

/** Chemins d’API Workers (InstantDevis) — rétrocompat : legacy = alias côté routeur serveur. */
const AF_WORKER_PATH = Object.freeze({
  ACOUSTIC: '/v1/agents/acoustic',
  SOURCING: '/v1/agents/sourcing',
  GUARDIAN: '/v1/agents/guardian',
  FISCAL: '/v1/agents/fiscal',
  FLUX: '/v1/agents/flux',
  LEGACY_VOCAL: '/v1/vocal-ingest',
  LEGACY_SEND: '/v1/send-devis',
});

const AF_WORKER = {
  /** Exposé pour diagnostic ; identique à `AF_WORKER_PATH` interne. */
  PATH: AF_WORKER_PATH,
  TIMEOUT_MS: 45000,
  /** Limite côté client : PDF base64 (~3 Mo binaire) — au-delà, envoi sans pièce. */
  MAX_PDF_BASE64_LEN: 4_200_000,
  /** Rejet avant fetch si le corps JSON dépasse ~6 Mo (évite plantages / limites). */
  MAX_JSON_BODY_LEN: 6_200_000,

  _getTimeoutMs() {
    const a = (typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION)
      ? Number(AF_INTEGRATION.workerTimeoutMs) : 0;
    if (a >= 5000 && a <= 300000) return a;
    return AF_WORKER.TIMEOUT_MS;
  },

  _baseVocal() {
    const p = typeof AF_PROFILE_STORE !== 'undefined' ? AF_PROFILE_STORE.get() : {};
    const fromProfile = (p.vocalWorkerBaseUrl || '').trim();
    const fromInt = (typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION.vocalWorkerBase)
      ? String(AF_INTEGRATION.vocalWorkerBase).trim()
      : '';
    return fromProfile || fromInt || '';
  },

  _baseMail() {
    return (typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION.mailWorkerBase)
      ? String(AF_INTEGRATION.mailWorkerBase).trim()
      : '';
  },

  _baseWorkers() {
    const fromInt = (typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION.workersBase)
      ? String(AF_INTEGRATION.workersBase).trim() : '';
    if (fromInt) return fromInt;
    const p = typeof AF_PROFILE_STORE !== 'undefined' ? AF_PROFILE_STORE.get() : {};
    return (p.workersBaseUrl || '').trim();
  },

  /** Acoustique (agent 1) : base unique `workersBase` **ou** repli `vocalWorkerBase` (profil + intégration). */
  _baseAcoustic() {
    return AF_WORKER._baseWorkers() || AF_WORKER._baseVocal();
  },

  /** Flux (agent 4) : base unique `workersBase` **ou** repli `mailWorkerBase`. */
  _baseFlux() {
    return AF_WORKER._baseWorkers() || AF_WORKER._baseMail();
  },

  /** Sourcing + Gardien (agents 2 & 3) : même hôte qu’un Worker unifié, ou premier vocal, sinon mail. */
  _baseAgentsJson() {
    const w = AF_WORKER._baseWorkers();
    if (w) return w;
    return AF_WORKER._baseVocal() || AF_WORKER._baseMail();
  },

  _getOptionalClientHeaders() {
    const o = (typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION) ? AF_INTEGRATION : {};
    const id = String(o.appClientId || '').trim();
    if (!id) return {};
    const name = String(o.appClientIdHeader || '').trim() || 'X-Instant-Devis-Client-Id';
    return { [name]: id };
  },

  /**
   * Valide et normalise une base Worker (http/https obligatoire, URL absolue).
   * @returns {string} chaîne prête à concaténer, ou '' si absente / invalide
   */
  _normalizeWorkerBase(raw) {
    const t0 = String(raw || '').trim();
    if (!t0) return '';
    const withProto = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(t0) ? t0 : `https://${t0}`;
    let u;
    try {
      u = new URL(withProto);
    } catch (e) {
      return '';
    }
    if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
    if (!u.hostname) return '';
    const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') : '';
    return (`${u.origin}${path}`).replace(/\/$/, '');
  },

  _parseResponseJson(textBody) {
    const t = (textBody == null ? '' : String(textBody)).trim();
    if (t.length === 0) {
      return { empty: true, json: null, parseError: null };
    }
    try {
      const j = JSON.parse(t);
      return { empty: false, json: j, parseError: null };
    } catch (e) {
      return { empty: false, json: null, parseError: e };
    }
  },

  _codeFromError(e) {
    if (e && e.name === 'AbortError') return 'timeout';
    const m = (e && e.message) ? String(e.message) : 'network';
    if (/network|failed to fetch|load failed|echec/i.test(m)) return 'network';
    return m.slice(0, 200) || 'network';
  },

  _userMessageVocal(ctx) {
    const c = ctx || {};
    if (c.skipped) return '';
    if (c.code === 'timeout') return 'Délai d’attente du service vocal dépassé. Mode local activé.';
    if (c.code === 'no_or_invalid_url') return 'Adresse du Worker vocal invalide. Vérifiez la base URL (HTTP/HTTPS).';
    if (c.code === 'empty_body' || c.code === 'invalid_json' || c.code === 'invalid_json_200') {
      return 'Réponse du service vocal illisible. Mode local activé.';
    }
    if (c.code === 'network') return 'Réseau indisponible vers le service vocal. Mode local activé.';
    if (c.httpStatus === 404) {
      return 'Service vocal introuvable (URL ou chemins /v1/agents/acoustic ou /v1/vocal-ingest).';
    }
    if (c.httpStatus === 413) return 'Fichier audio refusé (trop lourd) par le service.';
    if (c.httpStatus === 429) return 'Trop de requêtes. Réessayez plus tard.';
    if (c.httpStatus >= 500) return 'Erreur serveur du service vocal. Réessayez plus tard.';
    if (c.httpStatus >= 400) return 'Le service vocal a refusé la requête.';
    if (c.code) return `Service vocal : ${c.code}. Mode local activé.`;
    return 'Worker vocal indisponible. Mode local activé.';
  },

  _userMessageSend(ctx) {
    const c = ctx || {};
    if (c.skipped) return '';
    if (c.code === 'timeout') return 'Délai d’attente du service d’envoi (Worker) — envoi en secours (PDF + mail).';
    if (c.code === 'no_or_invalid_url') {
      return 'Adresse du Worker mail invalide ou absente. Vérifiez AF_INTEGRATION.mailWorkerBase (HTTP/HTTPS).';
    }
    if (c.code === 'json_too_large') {
      return 'Devis ou pièce jointe trop volumineux pour un envoi JSON — secours lancé.';
    }
    if (c.code === 'empty_body' || c.code === 'invalid_json' || c.code === 'invalid_json_200') {
      return 'Réponse du service d’envoi illisible (vide ou JSON invalide) — secours lancé.';
    }
    if (c.code === 'success_false') {
      return (c.data && c.data.message) ? String(c.data.message) : 'Le service a signalé un échec d’envoi — secours lancé.';
    }
    if (c.code === 'worker_skipped' || c.code === 'worker_body_skipped') {
      return 'L’envoi automatique a été ignoré côté service — secours lancé.';
    }
    if (c.code === 'network') return 'Réseau indisponible vers le Worker — secours lancé.';
    if (c.httpStatus === 404) {
      return 'Service d’envoi introuvable (URL ou chemin /v1/agents/flux ou /v1/send-devis) — secours lancé.';
    }
    if (c.httpStatus === 413) return 'Requête refusée (trop lourde) par le service — secours lancé.';
    if (c.httpStatus === 429) return 'Trop de requêtes vers le service — secours lancé.';
    if (c.httpStatus >= 500) return 'Erreur serveur du service d’envoi — secours lancé.';
    if (c.httpStatus >= 400) return 'Le service a refusé l’envoi (erreur ' + c.httpStatus + ') — secours lancé.';
    if (c.code) return 'Service d’envoi : ' + c.code + ' — secours lancé.';
    return 'Envoi via Worker indisponible — secours lancé.';
  },

  _userMessageAgent(name, ctx) {
    const c = ctx || {};
    if (c.skipped) return '';
    const n = (name || 'Agent').toString();
    if (c.code === 'timeout') return 'Délai dépassé — ' + n + ' indisponible.';
    if (c.code === 'no_or_invalid_url') return 'Adresse Worker invalide — ' + n + '.';
    if (c.code === 'empty_body' || c.code === 'invalid_json' || c.code === 'invalid_json_200') {
      return 'Réponse illisible — ' + n + '.';
    }
    if (c.code === 'network') return 'Réseau indisponible — ' + n + '.';
    if (c.code === 'json_too_large') return 'Requête trop volumineuse — ' + n + '.';
    if (c.httpStatus === 404) return 'Route introuvable — ' + n + '.';
    if (c.httpStatus && c.httpStatus >= 500) return 'Erreur serveur — ' + n + '.';
    if (c.httpStatus && c.httpStatus >= 400) return 'Requête refusée — ' + n + '.';
    if (c.code) return n + ' : ' + c.code;
    return n + ' indisponible.';
  },

  _finishGenericAgentResult({ httpStatus, textBody, parsed, agentName }) {
    const p = parsed || {};
    const an = (agentName || 'agent').toString();
    if (p.empty) {
      if (httpStatus >= 200 && httpStatus < 300) {
        return {
          ok: false, reason: 'empty_body', agent: an,
          userMessage: AF_WORKER._userMessageAgent(an, { code: 'empty_body' }),
          httpStatus, body: textBody, code: 'empty_body',
        };
      }
    }
    if (p.parseError) {
      return {
        ok: false, reason: 'invalid_json_200', agent: an, parseError: p.parseError,
        userMessage: AF_WORKER._userMessageAgent(an, { code: 'invalid_json_200' }),
        httpStatus, body: textBody, code: 'invalid_json_200',
      };
    }
    const j = p.json;
    if (j == null || typeof j !== 'object' || Array.isArray(j)) {
      return {
        ok: false, reason: 'invalid_json', agent: an,
        userMessage: AF_WORKER._userMessageAgent(an, { code: 'invalid_json' }),
        httpStatus, body: textBody, code: 'invalid_json',
      };
    }
    if (j.skipped === true) {
      return {
        ok: false, skipped: true, reason: 'agent_skipped', data: j, agent: an,
        userMessage: (j && j.message != null) ? String(j.message) : (an + ' ignoré côté service.'),
        httpStatus, code: 'agent_skipped',
      };
    }
    if (j.success === false) {
      return {
        ok: false, reason: 'success_false', data: j, agent: an,
        userMessage: (j && j.message) ? String(j.message) : (an + ' a signalé un échec.'),
        httpStatus, code: 'success_false',
      };
    }
    return { ok: true, data: j, httpStatus, code: 'ok', agent: an };
  },

  _finishVocalResult({ httpStatus, textBody, parsed }) {
    const p = parsed || {};
    if (p.empty) {
      if (httpStatus >= 200 && httpStatus < 300) {
        return {
          ok: false, partial: true, reason: 'empty_body', userMessage: AF_WORKER._userMessageVocal({ code: 'empty_body' }),
          httpStatus, body: textBody, code: 'empty_body',
        };
      }
    }
    if (p.parseError) {
      return {
        ok: false, partial: true, reason: 'invalid_json_200', userMessage: AF_WORKER._userMessageVocal({ code: 'invalid_json_200' }),
        httpStatus, body: textBody, code: 'invalid_json_200', parseError: p.parseError,
      };
    }
    if (p.json != null && typeof p.json === 'object' && !Array.isArray(p.json)) {
      return { ok: true, data: p.json, httpStatus };
    }
    return {
      ok: false, partial: true, reason: 'invalid_json', userMessage: AF_WORKER._userMessageVocal({ code: 'invalid_json' }),
      httpStatus, body: textBody, code: 'invalid_json',
    };
  },

  _finishSendResult({ httpStatus, textBody, parsed, pdfOmittedForSize }) {
    const p = parsed || {};
    if (p.empty) {
      if (httpStatus >= 200 && httpStatus < 300) {
        return {
          ok: false, reason: 'empty_body', userMessage: AF_WORKER._userMessageSend({ code: 'empty_body' }),
          httpStatus, body: textBody, code: 'empty_body', pdfOmittedForSize: !!pdfOmittedForSize,
        };
      }
    }
    if (p.parseError) {
      return {
        ok: false, reason: 'invalid_json_200', userMessage: AF_WORKER._userMessageSend({ code: 'invalid_json_200' }),
        httpStatus, body: textBody, code: 'invalid_json_200', parseError: p.parseError, pdfOmittedForSize: !!pdfOmittedForSize,
      };
    }
    const j = p.json;
    if (j == null || typeof j !== 'object' || Array.isArray(j)) {
      return {
        ok: false, reason: 'invalid_json', userMessage: AF_WORKER._userMessageSend({ code: 'invalid_json' }),
        httpStatus, body: textBody, code: 'invalid_json', pdfOmittedForSize: !!pdfOmittedForSize,
      };
    }
    if (j.skipped === true) {
      return {
        ok: false, skipped: true, reason: 'worker_body_skipped', data: j,
        userMessage: AF_WORKER._userMessageSend({ code: 'worker_body_skipped' }),
        httpStatus, code: 'worker_body_skipped', pdfOmittedForSize: !!pdfOmittedForSize,
      };
    }
    if (j.success === false) {
      return {
        ok: false, reason: 'success_false', data: j, userMessage: AF_WORKER._userMessageSend({ code: 'success_false', data: j }),
        httpStatus, code: 'success_false', pdfOmittedForSize: !!pdfOmittedForSize,
      };
    }
    return { ok: true, data: j, httpStatus, code: 'ok', pdfOmittedForSize: !!pdfOmittedForSize };
  },

  _buildSendPayload(payload) {
    const orig = payload && typeof payload === 'object' ? { ...payload } : {};
    let pdfOmittedForSize = false;
    if (orig.pdfBase64 && String(orig.pdfBase64).length > AF_WORKER.MAX_PDF_BASE64_LEN) {
      delete orig.pdfBase64;
      pdfOmittedForSize = true;
    }
    return { bodyObj: orig, pdfOmittedForSize };
  },

  _stringifySendBody(bodyObj) {
    let bodyStr;
    try {
      bodyStr = JSON.stringify(bodyObj);
    } catch (e) {
      return { err: e, str: null };
    }
    if (bodyStr.length > AF_WORKER.MAX_JSON_BODY_LEN) {
      const b = { ...bodyObj };
      delete b.pdfBase64;
      try {
        bodyStr = JSON.stringify(b);
      } catch (e2) {
        return { err: e2, str: null };
      }
      if (bodyStr.length > AF_WORKER.MAX_JSON_BODY_LEN) {
        return { err: new Error('json_too_large'), str: null, pdfOmitted: true, stripped: true };
      }
      return { err: null, str: bodyStr, pdfOmitted: true, stripped: true };
    }
    return { err: null, str: bodyStr, pdfOmitted: false, stripped: false };
  },

  /**
   * POST `multipart` — champ `audio` ; tente d’abord l’**agent Acoustique** puis repli `v1/vocal-ingest` (ou l’inverse si `preferLegacyVocalPath`). Seule la réponse **404** sur la première URL déclenche la seconde.
   * Réponse 2xx JSON : `{ transcript?, text?, items?|lignes?|lines? }` (même contrat qu’avant).
   */
  async _fetchFormDataPostOne(url, audioBlob, fileName) {
    const ctrl = new AbortController();
    const tid = setTimeout(() => ctrl.abort(), AF_WORKER._getTimeoutMs());
    let textBody = '';
    try {
      const fd = new FormData();
      fd.append('audio', audioBlob, fileName);
      const r = await fetch(url, {
        method: 'POST',
        body: fd,
        signal: ctrl.signal,
        headers: { ...AF_WORKER._getOptionalClientHeaders() },
      });
      clearTimeout(tid);
      textBody = await r.text().catch(() => '');
      const parsed = AF_WORKER._parseResponseJson(textBody);
      return { r, textBody, parsed, err: null };
    } catch (e) {
      clearTimeout(tid);
      const code = AF_WORKER._codeFromError(e);
      return { r: { status: 0, ok: false }, textBody, parsed: { empty: true, json: null, parseError: null }, err: code };
    }
  },

  async _fetchJsonPostOne(url, bodyStr) {
    const ctrl = new AbortController();
    const tid = setTimeout(() => ctrl.abort(), AF_WORKER._getTimeoutMs());
    let textBody = '';
    try {
      const r = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...AF_WORKER._getOptionalClientHeaders(),
        },
        body: bodyStr,
        signal: ctrl.signal,
      });
      clearTimeout(tid);
      textBody = await r.text().catch(() => '');
      const parsed = AF_WORKER._parseResponseJson(textBody);
      return { r, textBody, parsed, err: null };
    } catch (e) {
      clearTimeout(tid);
      const code = AF_WORKER._codeFromError(e);
      return { r: { status: 0, ok: false }, textBody, parsed: { empty: true, json: null, parseError: null }, err: code };
    }
  },

  /**
   * Appel interne : agents JSON (Sourcing, Gardien) sur la base unifiée ou (repli) vocale / mail.
   * @param {string} agentId
   * @param {string} pathSuffix — ex. `AF_WORKER.PATH.SOURCING`
   * @param {object} bodyObj
   * @param {string} label
   */
  async _agentJsonRequest(agentId, pathSuffix, bodyObj, label) {
    const baseRaw = AF_WORKER._baseAgentsJson();
    const base = AF_WORKER._normalizeWorkerBase(baseRaw);
    if (!baseRaw) {
      return { ok: false, skipped: true, reason: 'no_agent_base', userMessage: '', agent: agentId };
    }
    if (!base) {
      return {
        ok: false, skipped: false, code: 'no_or_invalid_url', reason: 'invalid_agent_url', agent: agentId,
        userMessage: AF_WORKER._userMessageAgent(label, { code: 'no_or_invalid_url' }),
      };
    }
    let bodyStr;
    try {
      bodyStr = JSON.stringify(bodyObj && typeof bodyObj === 'object' ? bodyObj : {});
    } catch (e) {
      return {
        ok: false, code: 'stringify', agent: agentId,
        userMessage: AF_WORKER._userMessageAgent(label, { code: 'network' }),
      };
    }
    if (bodyStr.length > AF_WORKER.MAX_JSON_BODY_LEN) {
      return { ok: false, code: 'json_too_large', agent: agentId, userMessage: AF_WORKER._userMessageAgent(label, { code: 'json_too_large' }) };
    }
    const res = await AF_WORKER._fetchJsonPostOne(base + pathSuffix, bodyStr);
    if (res.err) {
      return {
        ok: false, error: res.err, code: res.err, agent: agentId,
        userMessage: AF_WORKER._userMessageAgent(label, { code: res.err }),
        httpStatus: 0, body: res.textBody,
      };
    }
    if (!res.r.ok) {
      return {
        ok: false, status: res.r.status, httpStatus: res.r.status, body: res.textBody, json: res.parsed ? res.parsed.json : null, agent: agentId,
        userMessage: AF_WORKER._userMessageAgent(label, { httpStatus: res.r.status }),
        code: 'http_error', reason: 'http_error',
      };
    }
    return AF_WORKER._finishGenericAgentResult({
      httpStatus: res.r.status, textBody: res.textBody, parsed: res.parsed, agentName: label,
    });
  },

  /**
   * Agent Sourcing (POST JSON) : compare une ou plusieurs lignes au catalogue (local ou `catalogItems` dans le corps).
   * @param {object} body — ex. `{ ligne, lignes?, catalogItems?, trade?, context? }`
   */
  async sourcingPropose(body) {
    return AF_WORKER._agentJsonRequest('sourcing', AF_WORKER.PATH.SOURCING, body, 'Sourcing');
  },

  /**
   * Agent Gardien (POST JSON) : vérification / métadonnées de preuve côté serveur (sans remplacer `AF_DEVIS` côté client).
   * @param {object} body — ex. `{ devisId, signedDocumentHash?, signedSnapshot?, seal? }`
   */
  async guardianVerify(body) {
    return AF_WORKER._agentJsonRequest('guardian', AF_WORKER.PATH.GUARDIAN, body, 'Gardien');
  },

  /**
   * Agent Fiscal (POST JSON) : aide explicative à partir du **payload moteur local** `AF_FISCAL_ENGINE.buildFiscalAgentPayload` (ne remplace jamais le contrôle local).
   * Même base que Sourcing / Gardien (`_baseAgentsJson()`).
   */
  async fiscalExplain(body) {
    return AF_WORKER._agentJsonRequest('fiscal', AF_WORKER.PATH.FISCAL, body, 'Aide fiscale');
  },

  /**
   * Agent Acoustique + repli `v1/vocal-ingest` — FormData, champ `audio`.
   */
  async vocalIngest(audioBlob, fileName) {
    const baseRaw = AF_WORKER._baseAcoustic();
    const base = AF_WORKER._normalizeWorkerBase(baseRaw);
    const name = fileName || 'dictée.webm';
    if (!baseRaw) {
      return { ok: false, skipped: true, reason: 'no_vocal_base', userMessage: '' };
    }
    if (!base) {
      return {
        ok: false, skipped: false, reason: 'invalid_vocal_url', code: 'no_or_invalid_url',
        userMessage: AF_WORKER._userMessageVocal({ code: 'no_or_invalid_url' }),
      };
    }
    if (!audioBlob || audioBlob.size === 0) {
      return { ok: false, skipped: true, reason: 'no_worker_or_empty_audio', userMessage: '' };
    }
    const preferLegacy = !!(typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION.preferLegacyVocalPath);
    const pathFirst = preferLegacy ? AF_WORKER.PATH.LEGACY_VOCAL : AF_WORKER.PATH.ACOUSTIC;
    const pathSecond = preferLegacy ? AF_WORKER.PATH.ACOUSTIC : AF_WORKER.PATH.LEGACY_VOCAL;
    const res1 = await AF_WORKER._fetchFormDataPostOne(base + pathFirst, audioBlob, name);
    if (res1.err) {
      return {
        ok: false, error: res1.err, code: res1.err,
        userMessage: AF_WORKER._userMessageVocal({ code: res1.err, skipped: false }),
        httpStatus: 0, body: res1.textBody,
      };
    }
    if (res1.r.status === 404) {
      const res2 = await AF_WORKER._fetchFormDataPostOne(base + pathSecond, audioBlob, name);
      if (res2.err) {
        return {
          ok: false, error: res2.err, code: res2.err,
          userMessage: AF_WORKER._userMessageVocal({ code: res2.err, skipped: false }),
          httpStatus: 0, body: res2.textBody,
        };
      }
      if (!res2.r.ok) {
        return {
          ok: false, status: res2.r.status, httpStatus: res2.r.status, body: res2.textBody, json: res2.parsed.json,
          userMessage: AF_WORKER._userMessageVocal({ httpStatus: res2.r.status }),
          code: 'http_error', reason: 'http_error',
        };
      }
      return AF_WORKER._finishVocalResult({ httpStatus: res2.r.status, textBody: res2.textBody, parsed: res2.parsed });
    }
    if (!res1.r.ok) {
      return {
        ok: false, status: res1.r.status, httpStatus: res1.r.status, body: res1.textBody, json: res1.parsed.json,
        userMessage: AF_WORKER._userMessageVocal({ httpStatus: res1.r.status }),
        code: 'http_error', reason: 'http_error',
      };
    }
    return AF_WORKER._finishVocalResult({ httpStatus: res1.r.status, textBody: res1.textBody, parsed: res1.parsed });
  },

  /**
   * Agent Flux + repli `v1/send-devis` — JSON inchangé ; 404 seul sur la 1ʳᵉ URL déclenche la 2ᵉ.
   */
  async sendDevisJson(payload) {
    const baseRaw = AF_WORKER._baseFlux();
    const base = AF_WORKER._normalizeWorkerBase(baseRaw);
    if (!baseRaw) {
      return { ok: false, skipped: true, reason: 'no_mail_worker', userMessage: '' };
    }
    if (!base) {
      return {
        ok: false, skipped: false, reason: 'invalid_mail_url', code: 'no_or_invalid_url',
        userMessage: AF_WORKER._userMessageSend({ code: 'no_or_invalid_url' }),
      };
    }
    const { bodyObj, pdfOmittedForSize: pdfFromBase64 } = AF_WORKER._buildSendPayload(payload);
    const strRes = AF_WORKER._stringifySendBody(bodyObj);
    if (strRes.err) {
      if (strRes.err && strRes.err.message === 'json_too_large') {
        return {
          ok: false, reason: 'json_too_large', userMessage: AF_WORKER._userMessageSend({ code: 'json_too_large' }),
          code: 'json_too_large', pdfOmittedForSize: true,
        };
      }
      return {
        ok: false, reason: 'payload_not_serializable', userMessage: AF_WORKER._userMessageSend({ code: 'network' }),
        code: 'stringify', pdfOmittedForSize: !!(strRes.pdfOmitted),
      };
    }
    const bodyStr = strRes.str;
    const pdfOmittedForSize = pdfFromBase64 || strRes.pdfOmitted;
    const preferLegacy = !!(typeof AF_INTEGRATION !== 'undefined' && AF_INTEGRATION.preferLegacySendPath);
    const p1 = preferLegacy ? AF_WORKER.PATH.LEGACY_SEND : AF_WORKER.PATH.FLUX;
    const p2 = preferLegacy ? AF_WORKER.PATH.FLUX : AF_WORKER.PATH.LEGACY_SEND;
    const res1 = await AF_WORKER._fetchJsonPostOne(base + p1, bodyStr);
    if (res1.err) {
      return {
        ok: false, error: res1.err, code: res1.err,
        userMessage: AF_WORKER._userMessageSend({ code: res1.err }),
        httpStatus: 0, body: res1.textBody, pdfOmittedForSize: !!pdfOmittedForSize,
      };
    }
    if (res1.r.status === 404) {
      const res2 = await AF_WORKER._fetchJsonPostOne(base + p2, bodyStr);
      if (res2.err) {
        return {
          ok: false, error: res2.err, code: res2.err,
          userMessage: AF_WORKER._userMessageSend({ code: res2.err }),
          httpStatus: 0, body: res2.textBody, pdfOmittedForSize: !!pdfOmittedForSize,
        };
      }
      if (!res2.r.ok) {
        return {
          ok: false, status: res2.r.status, httpStatus: res2.r.status, body: res2.textBody, json: res2.parsed.json,
          userMessage: AF_WORKER._userMessageSend({ httpStatus: res2.r.status, body: res2.textBody }),
          code: 'http_error', reason: 'http_error', pdfOmittedForSize: !!pdfOmittedForSize,
        };
      }
      return AF_WORKER._finishSendResult({ httpStatus: res2.r.status, textBody: res2.textBody, parsed: res2.parsed, pdfOmittedForSize });
    }
    if (!res1.r.ok) {
      return {
        ok: false, status: res1.r.status, httpStatus: res1.r.status, body: res1.textBody, json: res1.parsed.json,
        userMessage: AF_WORKER._userMessageSend({ httpStatus: res1.r.status, body: res1.textBody }),
        code: 'http_error', reason: 'http_error', pdfOmittedForSize: !!pdfOmittedForSize,
      };
    }
    return AF_WORKER._finishSendResult({ httpStatus: res1.r.status, textBody: res1.textBody, parsed: res1.parsed, pdfOmittedForSize });
  },
};

const _normStatus = (d) => {
  const st = (d.status != null ? d.status : d.statut);
  const s = (st != null ? String(st) : 'brouillon').toLowerCase().replace(/\s/g, '');
  if (s === 'draft') return 'brouillon';
  if (s === 'sent') return 'sent';
  if (s === 'signed') return 'signe';
  if (s === 'paid') return 'paye';
  if (s === 'locked') return 'locked';
  if (s === 'envoyé' || s === 'envoye') return 'sent';
  if (s === 'signé' || s === 'signe') return 'signe';
  if (s === 'payé' || s === 'paye') return 'paye';
  if (s === 'brouillon') return 'brouillon';
  if (s === 'vu' || s === 'consulte' || s === 'consulté' || s === 'viewed') return 'viewed';
  if (s === 'enretard' || s === 'retard') return 'retard';
  return _CANON[s] ? s : 'brouillon';
};

const _COMPLIANCE_EVENT_TYPES = Object.freeze({
  CREATED: 'quote_created',
  UPDATED: 'quote_updated',
  SENT: 'quote_sent',
  SENT_FOR_SIGNATURE: 'quote_sent_for_signature',
  VIEWED: 'quote_viewed',
  SIGNED: 'quote_signed',
  LOCKED: 'quote_locked',
  DUPLICATED: 'quote_duplicated',
  VERSION_CREATED: 'quote_version_created',
  FISCAL_ANALYSIS_REQUESTED: 'fiscal_analysis_requested',
  FISCAL_ANALYSIS_APPLIED: 'fiscal_analysis_applied',
  CLIENT_EMAIL_UPDATED: 'client_email_updated',
});

const _genAuditId = () => `audit_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const _isValidEmailLite = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v || '').trim());
const _WORKSPACE_KEY = 'instantdevis_workspace_id';
const _DEVICE_KEY = 'instantdevis_device_id';
const _PROFILE_KEY = 'instantdevis_profile';
const _PROFILE_LEGACY_KEY = 'instantdevis_profile_v1';
const _APP_VERSION = 'pwa_local_v1';

const _randomHex = (bytes) => {
  try {
    if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
      const arr = new Uint8Array(bytes);
      crypto.getRandomValues(arr);
      return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
    }
  } catch (e) {}
  let out = '';
  for (let i = 0; i < bytes; i += 1) out += Math.floor(Math.random() * 256).toString(16).padStart(2, '0');
  return out;
};

function getOrCreateWorkspaceId() {
  try {
    const existing = String(localStorage.getItem(_WORKSPACE_KEY) || '').trim();
    if (existing && existing.startsWith('ws_')) return existing;
    const created = `ws_${_randomHex(12)}`;
    localStorage.setItem(_WORKSPACE_KEY, created);
    return created;
  } catch (e) {
    return `ws_${_randomHex(12)}`;
  }
}

function getOrCreateDeviceId() {
  try {
    const existing = String(localStorage.getItem(_DEVICE_KEY) || '').trim();
    if (existing && existing.startsWith('dev_')) return existing;
    const created = `dev_${_randomHex(12)}`;
    localStorage.setItem(_DEVICE_KEY, created);
    return created;
  } catch (e) {
    return `dev_${_randomHex(12)}`;
  }
}

const _buildLocalQuoteId = (quote) => {
  const q = quote || {};
  if (q.localQuoteId != null && String(q.localQuoteId).trim()) return String(q.localQuoteId).trim();
  if (q.id != null && String(q.id).trim()) return `lq_${String(q.id).trim()}`;
  return `lq_${_randomHex(10)}`;
};

function normalizeProfile(profile) {
  const p = (profile && typeof profile === 'object') ? profile : {};
  const norm = {
    workspaceId: String(p.workspaceId || '').trim() || getOrCreateWorkspaceId(),
    name: String(p.name || p.nom || '').trim(),
    companyName: String(p.companyName || p.entreprise || '').trim(),
    email: String(p.email || '').trim().toLowerCase(),
    phone: String(p.phone || p.tel || '').trim(),
    address: String(p.address || p.adresse || '').trim(),
    siret: String(p.siret || '').trim(),
    logoUrl: String(p.logoUrl || '').trim(),
    updatedAt: String(p.updatedAt || '').trim(),
  };
  return {
    ...p,
    ...norm,
    nom: norm.name,
    entreprise: norm.companyName,
    tel: norm.phone,
    adresse: norm.address,
  };
}

function loadProfile() {
  try {
    if (typeof AF_PROFILE_STORE !== 'undefined' && AF_PROFILE_STORE.get) {
      return normalizeProfile(AF_PROFILE_STORE.get());
    }
  } catch (e) {}
  try {
    const raw = localStorage.getItem(_PROFILE_KEY) || localStorage.getItem(_PROFILE_LEGACY_KEY);
    if (!raw) return normalizeProfile({});
    return normalizeProfile(JSON.parse(raw));
  } catch (e) {
    return normalizeProfile({});
  }
}

function saveProfile(profile) {
  const next = normalizeProfile(profile);
  next.updatedAt = new Date().toISOString();
  try {
    localStorage.setItem(_PROFILE_KEY, JSON.stringify(next));
    localStorage.setItem(_PROFILE_LEGACY_KEY, JSON.stringify(next));
  } catch (e) {}
  try {
    if (typeof AF_PROFILE_STORE !== 'undefined' && AF_PROFILE_STORE.save) {
      AF_PROFILE_STORE.save(next);
    }
  } catch (e) {}
  return next;
}

function isProfileCompleteForSignature(profile) {
  const p = normalizeProfile(profile);
  const hasIdentity = !!(p.companyName || p.name);
  return hasIdentity && _isValidEmailLite(p.email);
}

function getProfileForQuotePayload() {
  return normalizeProfile(loadProfile());
}

function addQuoteAuditEvent(quote, event) {
  const base = quote && typeof quote === 'object' ? { ...quote } : {};
  const existing = Array.isArray(base.auditTrail) ? [...base.auditTrail] : [];
  const e = event && typeof event === 'object' ? event : {};
  const nextEvent = {
    id: String(e.id || _genAuditId()),
    type: String(e.type || _COMPLIANCE_EVENT_TYPES.UPDATED),
    at: String(e.at || new Date().toISOString()),
    actor: (e.actor === 'artisan' || e.actor === 'client' || e.actor === 'system') ? e.actor : 'system',
    label: String(e.label || 'Événement devis'),
    metadata: (e.metadata && typeof e.metadata === 'object' && !Array.isArray(e.metadata)) ? e.metadata : {},
  };
  base.auditTrail = [...existing, nextEvent];
  return base;
}

function isQuoteLocked(quote) {
  if (!quote || typeof quote !== 'object') return true;
  const status = _normStatus(quote);
  if (quote.locked === true) return true;
  if (status === 'signe' || status === 'paye' || status === 'locked') return true;
  if (quote.signatureDataUrl || quote.signedAt || quote.signedSnapshot || quote.signedDocumentHash) return true;
  if (quote.fiscalOfficial === true || quote.isFiscalOfficial === true || quote.officialFiscal === true || quote.isInvoiced === true || quote.invoiced === true) return true;
  if (quote.legalSnapshot && (quote.legalSnapshot.lockedAt || quote.legalSnapshot.lockedReason)) return true;
  return false;
}

function canEditQuote(quote) {
  if (!quote || typeof quote !== 'object') return false;
  if (isQuoteLocked(quote)) return false;
  const st = _normStatus(quote);
  if (st === 'sent' || st === 'viewed') return false;
  return st === 'brouillon' || st === 'retard';
}

function canSendQuote(quote) {
  if (!quote || typeof quote !== 'object') return false;
  if (isQuoteLocked(quote)) return false;
  const st = _normStatus(quote);
  return st === 'brouillon' || st === 'retard';
}

function canSignQuote(quote) {
  if (!quote || typeof quote !== 'object') return false;
  if (isQuoteLocked(quote)) return false;
  const st = _normStatus(quote);
  return st === 'brouillon' || st === 'sent' || st === 'viewed' || st === 'retard';
}

function canDuplicateQuote(quote) {
  return !!(quote && typeof quote === 'object' && quote.id != null);
}

function getQuoteLegalState(quote) {
  if (!quote || typeof quote !== 'object') {
    return { state: 'unknown', label: 'État inconnu', locked: true, requiresNewVersion: false, reason: 'missing_quote' };
  }
  const st = _normStatus(quote);
  const locked = isQuoteLocked(quote);
  if (st === 'signe') return { state: 'signed', label: 'Signé', locked: true, requiresNewVersion: true, reason: 'signed' };
  if (st === 'paye') return { state: 'paid', label: 'Payé', locked: true, requiresNewVersion: true, reason: 'paid' };
  if (st === 'sent') return { state: 'sent', label: 'Envoyé', locked, requiresNewVersion: true, reason: 'sent_requires_version' };
  if (st === 'viewed') return { state: 'viewed', label: 'Consulté', locked, requiresNewVersion: true, reason: 'viewed_requires_version' };
  if (st === 'locked') return { state: 'locked', label: 'Verrouillé', locked: true, requiresNewVersion: true, reason: 'locked' };
  if (locked) return { state: 'locked', label: 'Verrouillé', locked: true, requiresNewVersion: true, reason: 'locked' };
  if (st === 'brouillon') return { state: 'draft', label: 'Brouillon', locked: false, requiresNewVersion: false, reason: 'draft' };
  return { state: st || 'draft', label: st || 'brouillon', locked, requiresNewVersion: !!locked, reason: locked ? 'locked' : 'editable' };
}

function _ensureQuoteLocalIds(quote) {
  const q = (quote && typeof quote === 'object') ? { ...quote } : {};
  if (!String(q.workspaceId || '').trim()) q.workspaceId = getOrCreateWorkspaceId();
  if (!String(q.createdFromDeviceId || '').trim()) q.createdFromDeviceId = getOrCreateDeviceId();
  if (!String(q.localQuoteId || '').trim()) q.localQuoteId = _buildLocalQuoteId(q);
  return q;
}

function normalizeQuote(d) {
  if (!d) return null;
  const raw = { ...d };
  ['signedAt', 'createdAtFull', 'createdAt', 'dueDate'].forEach((k) => {
    if (raw[k] != null && typeof raw[k] === 'object' && typeof raw[k].toDate === 'function') {
      try { raw[k] = raw[k].toDate().toISOString(); } catch (e) { /* laisser la valeur d’origine */ }
    }
  });
  const snap = raw.signedSnapshot && typeof raw.signedSnapshot === 'object' ? raw.signedSnapshot : null;

  let status = _normStatus(raw);
  if (snap && snap.totals && status !== 'paye') {
    status = 'signe';
  }

  let lignes;
  let totalHT;
  let totalTVA;
  let totalTTC;
  let acompte;
  let clientName = raw.clientName;
  let clientAddress = raw.clientAddress;

  if (snap && Array.isArray(snap.lignes) && snap.lignes.length && snap.totals) {
    lignes = snap.lignes.map((l) => ({
      label: String(l.label || '').trim(),
      qty: Number(l.qty) || 0,
      unit: String(l.unit || 'u'),
      pu: Number(l.pu) || 0,
      tva: l.tva != null ? Number(l.tva) : 20,
    }));
    totalHT = Number(snap.totals.totalHT);
    totalTVA = Number(snap.totals.totalTVA);
    totalTTC = Number(snap.totals.totalTTC);
    acompte = Number(snap.totals.acompte);
    if (snap.client) {
      if (snap.client.clientName) clientName = snap.client.clientName;
      if (snap.client.clientAddress) clientAddress = snap.client.clientAddress;
    }
  } else {
    lignes = Array.isArray(raw.lignes)
      ? raw.lignes
      : (Array.isArray(raw.items) && raw.items.length && typeof raw.items[0] === 'object'
        ? raw.items
        : []);
    const lineCount = lignes
      ? lignes.length
      : (typeof raw.items === 'number' ? raw.items : 0);
    const hasLines = lignes && lignes.length > 0;
    const t = hasLines
      ? AF_DEVIS.calcFromLignes(lignes)
      : null;
    totalTTC = raw.totalTTC != null
      ? Number(raw.totalTTC)
      : (raw.amount != null ? Number(raw.amount) : (t ? t.totalTTC : 0));
    totalHT = raw.totalHT != null
      ? Number(raw.totalHT)
      : (t ? t.totalHT : 0);
    totalTVA = raw.totalTVA != null
      ? Number(raw.totalTVA)
      : (t ? t.totalTVA : 0);
    const _pct2 = (typeof AF_PROFILE_STORE !== 'undefined' ? (AF_PROFILE_STORE.get().acomptePercent ?? 30) : 30);
    acompte = raw.acompte != null
      ? Number(raw.acompte)
      : Math.round(totalTTC * (_pct2 / 100) * 100) / 100;
    lignes = lignes || [];
    const lc = hasLines ? lignes.length : lineCount;
    let normalized = {
      ...raw,
      status,
      statut: raw.statut != null ? String(raw.statut) : status,
      lignes,
      items: lc,
      amount: totalTTC,
      totalTTC,
      totalHT: hasLines ? t.totalHT : (totalTTC && !hasLines ? totalTTC * 0.85 : totalHT),
      totalTVA: hasLines ? t.totalTVA : (totalTTC && !hasLines ? totalTTC * 0.15 : totalTVA),
      acompte,
    };
    if (normalized.version == null || !Number.isFinite(Number(normalized.version))) normalized.version = 1;
    normalized.version = Math.max(1, Number(normalized.version) || 1);
    if (normalized.previousVersionId == null) normalized.previousVersionId = null;
    if (normalized.officialVersionId == null) normalized.officialVersionId = null;
    if (!normalized.legalSnapshot || typeof normalized.legalSnapshot !== 'object') {
      normalized.legalSnapshot = {
        quoteHash: null,
        pdfHash: null,
        lockedAt: null,
        lockedReason: null,
        sourceVersion: null,
      };
    } else {
      normalized.legalSnapshot = {
        quoteHash: normalized.legalSnapshot.quoteHash ?? null,
        pdfHash: normalized.legalSnapshot.pdfHash ?? null,
        lockedAt: normalized.legalSnapshot.lockedAt ?? null,
        lockedReason: normalized.legalSnapshot.lockedReason ?? null,
        sourceVersion: normalized.legalSnapshot.sourceVersion ?? null,
      };
    }
    if (!Array.isArray(normalized.auditTrail)) {
      normalized.auditTrail = [];
    }
    normalized.locked = isQuoteLocked(normalized);
    if (normalized.locked && !normalized.legalSnapshot.lockedAt) {
      normalized.legalSnapshot.lockedAt = String(normalized.signedAt || new Date().toISOString());
    }
    if (normalized.locked && !normalized.legalSnapshot.lockedReason) {
      const s = _normStatus(normalized);
      normalized.legalSnapshot.lockedReason = s === 'signe' ? 'signed' : (s === 'paye' ? 'paid' : 'locked');
    }
    if (!normalized.auditTrail.length && normalized.createdAt) {
      normalized = addQuoteAuditEvent(normalized, {
        type: _COMPLIANCE_EVENT_TYPES.CREATED,
        actor: 'artisan',
        label: 'Devis créé',
        at: normalized.createdAtFull || normalized.createdAt,
        metadata: { importedLegacy: true },
      });
    }
    return _ensureQuoteLocalIds(normalized);
  }

  let normalized = {
    ...raw,
    status,
    statut: raw.statut != null ? String(raw.statut) : status,
    lignes,
    items: lignes.length,
    amount: totalTTC,
    totalTTC,
    totalHT,
    totalTVA,
    acompte,
    clientName,
    clientAddress,
  };
  if (normalized.version == null || !Number.isFinite(Number(normalized.version))) normalized.version = 1;
  normalized.version = Math.max(1, Number(normalized.version) || 1);
  if (normalized.previousVersionId == null) normalized.previousVersionId = null;
  if (normalized.officialVersionId == null) normalized.officialVersionId = null;
  if (!normalized.legalSnapshot || typeof normalized.legalSnapshot !== 'object') {
    normalized.legalSnapshot = {
      quoteHash: null,
      pdfHash: null,
      lockedAt: null,
      lockedReason: null,
      sourceVersion: null,
    };
  } else {
    normalized.legalSnapshot = {
      quoteHash: normalized.legalSnapshot.quoteHash ?? null,
      pdfHash: normalized.legalSnapshot.pdfHash ?? null,
      lockedAt: normalized.legalSnapshot.lockedAt ?? null,
      lockedReason: normalized.legalSnapshot.lockedReason ?? null,
      sourceVersion: normalized.legalSnapshot.sourceVersion ?? null,
    };
  }
  if (!Array.isArray(normalized.auditTrail)) {
    normalized.auditTrail = [];
  }
  normalized.locked = isQuoteLocked(normalized);
  if (normalized.locked && !normalized.legalSnapshot.lockedAt) {
    normalized.legalSnapshot.lockedAt = String(normalized.signedAt || new Date().toISOString());
  }
  if (normalized.locked && !normalized.legalSnapshot.lockedReason) {
    const s = _normStatus(normalized);
    normalized.legalSnapshot.lockedReason = s === 'signe' ? 'signed' : (s === 'paye' ? 'paid' : 'locked');
  }
  if (!normalized.auditTrail.length && normalized.createdAt) {
    normalized = addQuoteAuditEvent(normalized, {
      type: _COMPLIANCE_EVENT_TYPES.CREATED,
      actor: 'artisan',
      label: 'Devis créé',
      at: normalized.createdAtFull || normalized.createdAt,
      metadata: { importedLegacy: true },
    });
  }
  return _ensureQuoteLocalIds(normalized);
};

const _normalizeDevis = normalizeQuote;

function createQuoteVersionPayload(sourceQuote) {
  const src = normalizeQuote(sourceQuote);
  if (!src) return null;
  const next = {
    ...src,
    id: _nextId(),
    localQuoteId: _buildLocalQuoteId(src),
    workspaceId: String(src.workspaceId || getOrCreateWorkspaceId()).trim(),
    createdFromDeviceId: String(src.createdFromDeviceId || getOrCreateDeviceId()).trim(),
    status: 'brouillon',
    statut: 'brouillon',
    locked: false,
    signedAt: null,
    signerName: null,
    signatureDataUrl: null,
    consentText: null,
    signedSnapshot: null,
    signedDocumentHash: null,
    version: (Number(src.version) || 1) + 1,
    previousVersionId: src.id || src.previousVersionId || null,
    officialVersionId: src.officialVersionId || src.id || null,
    legalSnapshot: {
      quoteHash: null,
      pdfHash: null,
      lockedAt: null,
      lockedReason: null,
      sourceVersion: src.id || src.previousVersionId || null,
    },
    createdAt: new Date().toISOString().split('T')[0],
    createdAtFull: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
  const withoutAudit = { ...next, auditTrail: Array.isArray(src.auditTrail) ? [...src.auditTrail] : [] };
  return addQuoteAuditEvent(withoutAudit, {
    type: _COMPLIANCE_EVENT_TYPES.VERSION_CREATED,
    actor: 'artisan',
    label: 'Nouvelle version créée',
    metadata: {
      sourceQuoteId: src.id || null,
      newVersion: withoutAudit.version,
      previousVersionId: withoutAudit.previousVersionId,
    },
  });
}

/**
 * Date utilisée pour « signé ce mois » : priorité `signedAt`, sinon `signedSnapshot.signedAt`,
 * puis `createdAtFull`, puis `createdAt` (démo / legacy sans horodatage de signature explicite).
 * Retourne `null` si aucune date exploitable.
 */
const _parseDevisDate = (v) => {
  if (v == null || v === '') return null;
  const t = new Date(v);
  return Number.isNaN(t.getTime()) ? null : t;
};

const _signeReferenceDate = (q) => {
  if (!q || q.status !== 'signe') return null;
  const a = _parseDevisDate(q.signedAt);
  if (a) return a;
  if (q.signedSnapshot && typeof q.signedSnapshot === 'object' && q.signedSnapshot.signedAt) {
    const b = _parseDevisDate(q.signedSnapshot.signedAt);
    if (b) return b;
  }
  const c = _parseDevisDate(q.createdAtFull);
  if (c) return c;
  return _parseDevisDate(q.createdAt);
};

let _db = null;
let _firebaseReady = false;
let _auth = null;
let _authHasModule = false;

const _initAuth = () => {
  if (typeof firebase === 'undefined' || !firebase.auth) {
    _authHasModule = false;
    return;
  }
  _authHasModule = true;
  if (!_firebaseReady) return;
  try {
    if (!_auth) {
      _auth = firebase.auth();
    }
  } catch (e) {
    console.warn('[InstantDevis] Firebase Auth:', e && e.message);
  }
};

const _tryInitFirebase = () => {
  const hasConfig = AF_FIREBASE_CONFIG.apiKey && AF_FIREBASE_CONFIG.projectId;
  if (!hasConfig) {
    console.info('[InstantDevis] Firebase non configuré → mode localStorage seul');
    return;
  }
  try {
    if (!firebase.apps?.length) {
      firebase.initializeApp(AF_FIREBASE_CONFIG);
    }
    _db = firebase.firestore();
    _firebaseReady = true;
    _initAuth();
    console.info('[InstantDevis] ✅ Firebase Firestore connecté');
  } catch (err) {
    console.warn('[InstantDevis] Firebase init échoué (mode localStorage):', err.message);
  }
};

if (typeof firebase !== 'undefined') _tryInitFirebase();

const _getUidSync = () => {
  if (_auth && _auth.currentUser) return _auth.currentUser.uid;
  return null;
};

/**
 * Assure un utilisateur Firebase (Auth) pour que request.auth soit valide côté règles.
 * Tente signInAnonymously si le projet a activé l’authentification anonyme.
 * @returns {Promise<string|null>} uid ou null (mode 100% local, ou Auth indisponible)
 */
const _ensureAuth = async () => {
  if (!_authHasModule) return _getUidSync();
  if (!_auth) {
    if (_firebaseReady) _initAuth();
  }
  if (!_auth) return null;
  if (_auth.currentUser) return _auth.currentUser.uid;
  try {
    const cred = await _auth.signInAnonymously();
    return cred && cred.user ? cred.user.uid : null;
  } catch (e) {
    console.warn('[InstantDevis] signInAnonymously indisponible (activez l’Anonyme dans la console ou connectez l’app):', e && e.message);
    return null;
  }
};

const AF_AUTH = {
  get currentUser() {
    return _auth && _auth.currentUser ? _auth.currentUser : null;
  },
  get uid() {
    return _getUidSync();
  },
  isConfigured() {
    return !!(_firebaseReady && _db && _authHasModule);
  },
  /**
   * @returns {Promise<string|null>} uid
   */
  ensureSignedIn() {
    return _ensureAuth();
  },
};


const LS_KEY = 'instantdevis_quotes_v1';

const _loadFromLS = () => {
  try {
    const raw = localStorage.getItem(LS_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch { return []; }
};

const _saveToLS = (quotes) => {
  try {
    localStorage.setItem(LS_KEY, JSON.stringify(quotes));
  } catch (e) {
    console.warn('[InstantDevis] localStorage write échoué:', e);
  }
};

const _demoDataOn = () => (typeof afIsDemoDataEnabled === 'function' && afIsDemoDataEnabled());

const _nextId = () => {
  const year = new Date().getFullYear();
  const existing = _demoDataOn() ? [...AF_QUOTES, ..._loadFromLS()] : _loadFromLS();
  const nums = existing
    .map(q => parseInt((q.id || '').split('-')[2], 10))
    .filter(n => !isNaN(n));
  const next = nums.length ? Math.max(...nums) + 1 : 1;
  return `Q-${year}-${String(next).padStart(3, '0')}`;
};

/** Métadonnée **locale uniquement** (jamais envoyée à Firestore). */
const AF_LOCAL_SYNC = Object.freeze({
  LOCAL_ONLY: 'local_only',
  PENDING: 'pending',
  SYNCED: 'synced',
  ERROR: 'error',
});

const _localSyncNow = (partial) => ({
  ...partial,
  at: new Date().toISOString(),
});

const _localSyncMetaFromWriteResult = (fsRes) => {
  if (fsRes.ok && !fsRes.skipped) {
    return _localSyncNow({ state: AF_LOCAL_SYNC.SYNCED });
  }
  if (fsRes.skipped) {
    if (!_firebaseReady || !_db) {
      return _localSyncNow({
        state: AF_LOCAL_SYNC.LOCAL_ONLY,
        detail: 'Cloud non configuré — données sur cet appareil.',
      });
    }
    if (fsRes.reason === 'no_auth') {
      return _localSyncNow({
        state: AF_LOCAL_SYNC.LOCAL_ONLY,
        detail: 'Authentification cloud requise pour la synchronisation — données conservées en local sur cet appareil.',
      });
    }
    return _localSyncNow({
      state: AF_LOCAL_SYNC.LOCAL_ONLY,
      detail: 'Synchronisation non effectuée (mode local).',
    });
  }
  if (fsRes.code === 'local_sealed' || fsRes.code === 'owner_mismatch') {
    return _localSyncNow({
      state: AF_LOCAL_SYNC.ERROR,
      detail: fsRes.userMessage || 'Écriture refusée par le service.',
    });
  }
  return _localSyncNow({
    state: AF_LOCAL_SYNC.ERROR,
    detail: fsRes.userMessage || 'Échec d’enregistrement sur le cloud.',
  });
};

const _stripForFirestore = (o) => {
  const out = {};
  for (const k of Object.keys(o)) {
    if (k === 'localSync') continue;
    if (o[k] !== undefined) out[k] = o[k];
  }
  return out;
};

const _firestoreErrorToMessage = (err) => {
  if (!err) return 'Erreur de sauvegarde distante inconnue.';
  const code = err.code != null ? String(err.code) : '';
  if (code === 'permission-denied') {
    return 'Accès refusé : droits insuffisants sur la base ou document protégé (signé / payé / règles de sécurité).';
  }
  if (code === 'unavailable' || code === 'deadline-exceeded') {
    return 'Service de synchronisation temporairement indisponible. Réessayez plus tard.';
  }
  if (code === 'failed-precondition') {
    return 'L’opération a été rejetée par la base (condition non remplie).';
  }
  if (code === 'resource-exhausted') {
    return 'Quota ou limite côté base atteinte.';
  }
  if (err.message) return String(err.message);
  return 'Erreur de sauvegarde distante.';
};

let _lastFirestoreError = null;
const _setLastFirestoreError = (msg) => {
  _lastFirestoreError = (msg == null || msg === '') ? null : String(msg);
};

/**
 * Écriture Firestore : interdite pour un devis scellé (signé / paye), sauf allowSealedWrite
 * (p. ex. premier envoi des champs de signature après application du sceau).
 * Règles: request.auth + ownerUid cohérents avec auth.uid
 */
const _saveToFirestore = async (devis, options) => {
  if (!_firebaseReady || !_db) {
    return { ok: true, skipped: true };
  }
  const opts = options || {};
  if (devis && _isReadOnlyDevis(devis) && !opts.allowSealedWrite) {
    const msg = 'Écriture cloud refusée : devis en lecture seule (signé ou payé).';
    console.warn('[InstantDevis]', msg, devis && devis.id);
    _setLastFirestoreError(msg);
    return { ok: false, code: 'local_sealed', userMessage: msg };
  }
  let authUid = _getUidSync();
  if (!authUid) {
    authUid = await _ensureAuth();
  }
  if (!authUid) {
    const msg = 'Compte requis (Firebase Auth) pour la synchronisation cloud. Données conservées en local uniquement.';
    _setLastFirestoreError(msg);
    return { ok: true, skipped: true, reason: 'no_auth' };
  }
  const withOwner = { ...devis, ownerUid: (devis && devis.ownerUid) ? String(devis.ownerUid) : authUid };
  if (String(withOwner.ownerUid) !== String(authUid)) {
    const msg = 'Propriétaire du devis (ownerUid) incompatible avec le compte connecté.';
    _setLastFirestoreError(msg);
    return { ok: false, code: 'owner_mismatch', userMessage: msg };
  }
  try {
    const payload = _stripForFirestore(_normalizeDevis(withOwner));
    await _db.collection('devis').doc(String(devis.id)).set(payload, { merge: true });
    _setLastFirestoreError(null);
    console.info('[InstantDevis] ✅ Devis sauvegardé Firestore:', devis.id);
    return { ok: true };
  } catch (err) {
    const userMessage = _firestoreErrorToMessage(err);
    _setLastFirestoreError(userMessage);
    console.warn('[InstantDevis] Firestore write échoué:', err.code || err.name, err.message);
    return { ok: false, code: err && err.code, userMessage };
  }
};

const _isReadOnlyDevis = (d) => {
  if (!d) return true;
  return isQuoteLocked(d);
};

const AF_STORE = (() => {
  let _listeners = [];
  let _userQuotes = _loadFromLS();

  const _notify = () => {
    _listeners.forEach(fn => fn([..._userQuotes]));
  };

  return {
    getAll() {
      const merged = _demoDataOn() ? [..._userQuotes, ...AF_QUOTES] : [..._userQuotes];
      return merged.map((q) => _normalizeDevis(q));
    },

    getUserQuotes() {
      return [..._userQuotes].map((q) => _normalizeDevis(q));
    },

    getDevisById(id) {
      if (id == null || id === '') return null;
      const sid = String(id);
      const fromUser = _userQuotes.find((q) => String(q.id) === sid);
      if (fromUser) return _normalizeDevis(fromUser);
      if (_demoDataOn() && typeof AF_QUOTES !== 'undefined') {
        const st = AF_QUOTES.find((q) => String(q.id) === sid);
        if (st) return _normalizeDevis(st);
      }
      return null;
    },

    isDevisReadOnlyById(id) {
      return isQuoteLocked(this.getDevisById(id)) || !canEditQuote(this.getDevisById(id));
    },

    async applySignatureSeal(devisId, seal) {
      const id = String(devisId || '');
      const fail = (reason) => ({ ok: false, reason });
      if (!id) return fail('no_id');
      if (!seal || typeof seal !== 'object') return fail('no_seal');
      if (!seal.signatureDataUrl || typeof seal.signatureDataUrl !== 'string') return fail('no_signature_image');
      if (!seal.signedSnapshot || typeof seal.signedSnapshot !== 'object') return fail('no_snapshot');
      if (!seal.signedDocumentHash || typeof seal.signedDocumentHash !== 'string') return fail('no_hash');
      if (!seal.consentText || typeof seal.consentText !== 'string') return fail('no_consent');
      if (!/^[a-f0-9]{64}$/.test(seal.signedDocumentHash)) return fail('invalid_hash_format');
      {
        const exp = await AF_DEVIS.expectedHashFromSignatureSeal(id, seal);
        if (exp.err) return fail('seal_inconsistent');
        if (String(exp.hash).toLowerCase() !== String(seal.signedDocumentHash).toLowerCase()) {
          return fail('hash_mismatch');
        }
      }

      const idx = _userQuotes.findIndex((q) => String(q.id) === id);
      if (idx !== -1) {
        const curN = _normalizeDevis(_userQuotes[idx]);
        if (_isReadOnlyDevis(curN)) return fail('already_locked');
        const authU = (await _ensureAuth()) || _getUidSync() || '';
        const ownerForDoc = String(_userQuotes[idx].ownerUid || authU);
        let merged = {
          ..._userQuotes[idx],
          ownerUid: ownerForDoc || authU,
          status: 'signe',
          statut: 'signe',
          signedAt: seal.signedAt || new Date().toISOString(),
          signerName: String(seal.signerName || ''),
          signatureDataUrl: seal.signatureDataUrl,
          consentText: String(seal.consentText),
          signedSnapshot: seal.signedSnapshot,
          signedDocumentHash: seal.signedDocumentHash,
        };
        merged.locked = true;
        merged.legalSnapshot = {
          ...(merged.legalSnapshot && typeof merged.legalSnapshot === 'object' ? merged.legalSnapshot : {}),
          quoteHash: merged.legalSnapshot && Object.prototype.hasOwnProperty.call(merged.legalSnapshot, 'quoteHash') ? merged.legalSnapshot.quoteHash : null,
          pdfHash: merged.legalSnapshot && Object.prototype.hasOwnProperty.call(merged.legalSnapshot, 'pdfHash') ? merged.legalSnapshot.pdfHash : null,
          lockedAt: merged.signedAt || new Date().toISOString(),
          lockedReason: 'signed',
          sourceVersion: (merged.legalSnapshot && merged.legalSnapshot.sourceVersion) || null,
        };
        merged = addQuoteAuditEvent(merged, {
          type: _COMPLIANCE_EVENT_TYPES.SIGNED,
          actor: 'client',
          label: 'Devis signé par le client',
          metadata: { signerName: String(seal.signerName || '') },
        });
        merged = addQuoteAuditEvent(merged, {
          type: _COMPLIANCE_EVENT_TYPES.LOCKED,
          actor: 'system',
          label: 'Devis verrouillé après signature',
          metadata: { reason: 'signed' },
        });
        if (_firebaseReady && _db) {
          merged.localSync = _localSyncNow({ state: AF_LOCAL_SYNC.PENDING });
        } else {
          merged.localSync = _localSyncNow({
            state: AF_LOCAL_SYNC.LOCAL_ONLY,
            detail: 'Cloud non disponible — signature conservée en local sur cet appareil.',
          });
        }
        _userQuotes[idx] = merged;
        _saveToLS(_userQuotes);
        _notify();
        const fsRes = await _saveToFirestore(merged, { allowSealedWrite: true });
        _userQuotes[idx] = { ..._userQuotes[idx], localSync: _localSyncMetaFromWriteResult(fsRes) };
        _saveToLS(_userQuotes);
        _notify();
        if (!fsRes.ok && !fsRes.skipped) {
          const syncMsg = _userQuotes[idx].localSync && _userQuotes[idx].localSync.detail
            ? _userQuotes[idx].localSync.detail
            : (fsRes.userMessage || 'Échec de synchronisation cloud (signature enregistrée en local).');
          _setLastFirestoreError('Devis ' + id + ' — ' + syncMsg);
          return { ok: true, cloudSyncPending: true };
        }
        return { ok: true };
      }
      if (_demoDataOn() && typeof AF_QUOTES !== 'undefined') {
        const staticIdx = AF_QUOTES.findIndex((q) => String(q.id) === id);
        if (staticIdx !== -1) {
          const curN = _normalizeDevis(AF_QUOTES[staticIdx]);
          if (_isReadOnlyDevis(curN)) return fail('already_locked');
          const withSignedAudit = addQuoteAuditEvent({
            ...AF_QUOTES[staticIdx],
            auditTrail: Array.isArray(AF_QUOTES[staticIdx].auditTrail) ? AF_QUOTES[staticIdx].auditTrail : [],
          }, {
            type: _COMPLIANCE_EVENT_TYPES.SIGNED,
            actor: 'client',
            label: 'Devis signé par le client',
            metadata: { signerName: String(seal.signerName || '') },
          });
          const withLockAudit = addQuoteAuditEvent(withSignedAudit, {
            type: _COMPLIANCE_EVENT_TYPES.LOCKED,
            actor: 'system',
            label: 'Devis verrouillé après signature',
            metadata: { reason: 'signed' },
          });
          Object.assign(AF_QUOTES[staticIdx], {
            ...withLockAudit,
            status: 'signe',
            statut: 'signe',
            locked: true,
            signedAt: seal.signedAt || new Date().toISOString(),
            signerName: String(seal.signerName || ''),
            signatureDataUrl: seal.signatureDataUrl,
            consentText: String(seal.consentText),
            signedSnapshot: seal.signedSnapshot,
            signedDocumentHash: seal.signedDocumentHash,
            legalSnapshot: {
              ...(AF_QUOTES[staticIdx].legalSnapshot && typeof AF_QUOTES[staticIdx].legalSnapshot === 'object' ? AF_QUOTES[staticIdx].legalSnapshot : {}),
              quoteHash: AF_QUOTES[staticIdx].legalSnapshot && Object.prototype.hasOwnProperty.call(AF_QUOTES[staticIdx].legalSnapshot, 'quoteHash') ? AF_QUOTES[staticIdx].legalSnapshot.quoteHash : null,
              pdfHash: AF_QUOTES[staticIdx].legalSnapshot && Object.prototype.hasOwnProperty.call(AF_QUOTES[staticIdx].legalSnapshot, 'pdfHash') ? AF_QUOTES[staticIdx].legalSnapshot.pdfHash : null,
              lockedAt: seal.signedAt || new Date().toISOString(),
              lockedReason: 'signed',
              sourceVersion: (AF_QUOTES[staticIdx].legalSnapshot && AF_QUOTES[staticIdx].legalSnapshot.sourceVersion) || null,
            },
          });
          _notify();
          return { ok: true };
        }
      }
      return fail('not_found');
    },

    async addDevis(devisData) {
      const ownerUid = (await _ensureAuth()) || _getUidSync() || '';
      const now = new Date();
      const profile = getProfileForQuotePayload();
      const workspaceId = getOrCreateWorkspaceId();
      const createdFromDeviceId = getOrCreateDeviceId();
      const itemsArr = devisData.items;
      const lignes = Array.isArray(devisData.lignes)
        ? devisData.lignes
        : (Array.isArray(itemsArr) ? itemsArr : []);
      const totals = lignes.length ? AF_DEVIS.calcFromLignes(lignes) : {
        totalHT: Number(devisData.totalHT) || 0,
        totalTVA: Number(devisData.totalTVA) || 0,
        totalTTC: Number(devisData.totalTTC) || 0,
        acompte: 0,
      };
      if (!lignes.length) {
        const _apEmpty = (typeof AF_PROFILE_STORE !== 'undefined' ? (AF_PROFILE_STORE.get().acomptePercent ?? 30) : 30);
        totals.acompte = devisData.acompte != null
          ? Number(devisData.acompte)
          : Math.round(totals.totalTTC * (_apEmpty / 100) * 100) / 100;
      } else {
        totals.acompte = devisData.acompte != null
          ? Number(devisData.acompte)
          : totals.acompte;
      }
      const devis = {
        id: _nextId(),
        localQuoteId: _buildLocalQuoteId(devisData),
        workspaceId,
        createdFromDeviceId,
        clientId: devisData.clientId || 'new',
        client: devisData.client || devisData.clientId,
        clientName: devisData.clientName || 'Nouveau client',
        title: devisData.title || 'Nouveau devis',
        status: 'brouillon',
        statut: 'brouillon',
        amount: totals.totalTTC,
        totalTTC: totals.totalTTC,
        totalHT: totals.totalHT,
        totalTVA: totals.totalTVA,
        paid: 0,
        dueDate: null,
        createdAt: now.toISOString().split('T')[0],
        items: lignes.length,
        lignes,
        deposit: 30,
        acompte: totals.acompte,
        trade: devisData.trade || 'multi',
        source: 'voice',
        createdAtFull: now.toISOString(),
        updatedAt: now.toISOString(),
        photos: devisData.photos && Array.isArray(devisData.photos) ? devisData.photos : [],
        company: {
          ...(devisData.company && typeof devisData.company === 'object' ? devisData.company : {}),
          name: String((devisData.company && devisData.company.name) || devisData.companyName || profile.companyName || '').trim(),
          email: String((devisData.company && devisData.company.email) || profile.email || '').trim().toLowerCase(),
          phone: String((devisData.company && devisData.company.phone) || profile.phone || '').trim(),
          address: String((devisData.company && devisData.company.address) || profile.address || '').trim(),
        },
        artisan: {
          ...(devisData.artisan && typeof devisData.artisan === 'object' ? devisData.artisan : {}),
          name: String((devisData.artisan && devisData.artisan.name) || devisData.artisanName || profile.name || '').trim(),
          email: String((devisData.artisan && devisData.artisan.email) || devisData.artisanEmail || profile.email || '').trim().toLowerCase(),
        },
        companyName: String(devisData.companyName || profile.companyName || '').trim(),
        artisanEmail: String(devisData.artisanEmail || profile.email || '').trim().toLowerCase(),
        ownerUid,
        version: 1,
        previousVersionId: null,
        officialVersionId: null,
        legalSnapshot: {
          quoteHash: null,
          pdfHash: null,
          lockedAt: null,
          lockedReason: null,
          sourceVersion: null,
        },
        auditTrail: [],
      };
      let devisToStore = addQuoteAuditEvent(devis, {
        type: _COMPLIANCE_EVENT_TYPES.CREATED,
        actor: 'artisan',
        label: 'Devis créé',
        metadata: { source: devis.source || 'manual' },
      });

      if (_firebaseReady && _db) {
        devisToStore.localSync = _localSyncNow({ state: AF_LOCAL_SYNC.PENDING });
      } else {
        devisToStore.localSync = _localSyncNow({
          state: AF_LOCAL_SYNC.LOCAL_ONLY,
          detail: 'Cloud non configuré — devis enregistré sur cet appareil uniquement.',
        });
      }

      _userQuotes = [devisToStore, ..._userQuotes];
      _saveToLS(_userQuotes);
      _notify();
      const fsRes = await _saveToFirestore(devisToStore, {});
      devisToStore.localSync = _localSyncMetaFromWriteResult(fsRes);
      _saveToLS(_userQuotes);
      _notify();
      return _normalizeDevis(devisToStore);
    },

    async updateDevis(id, updates) {
      if (id == null || id === '') return undefined;
      const sid = String(id);
      const userIdx = _userQuotes.findIndex((q) => String(q.id) === sid);
      const staticIdx = (_demoDataOn() && typeof AF_QUOTES !== 'undefined')
        ? AF_QUOTES.findIndex((q) => String(q.id) === sid)
        : -1;
      if (userIdx === -1 && staticIdx === -1) return { ok: false, reason: 'not_found' };

      const existing = userIdx !== -1 ? _userQuotes[userIdx] : AF_QUOTES[staticIdx];
      const curLocked = _normalizeDevis(existing);
      if (!canEditQuote(curLocked)) {
        console.warn('[InstantDevis] Devis verrouillé (signé/payé) — updateDevis refusé');
        return {
          ok: false,
          reason: 'read_only',
          userMessage: 'Modification impossible : devis verrouillé, signé, envoyé ou fiscalement officiel.',
        };
      }

      const computedPatch = { ...updates };
      const customAuditEvent = (computedPatch._auditEvent && typeof computedPatch._auditEvent === 'object')
        ? computedPatch._auditEvent
        : null;
      if (Object.prototype.hasOwnProperty.call(computedPatch, '_auditEvent')) delete computedPatch._auditEvent;
      const sealLike = ['signedSnapshot', 'signedDocumentHash', 'signatureDataUrl', 'signedAt', 'consentText', 'signerName']
        .some((k) => Object.prototype.hasOwnProperty.call(computedPatch, k));
      if (sealLike) {
        console.warn('[InstantDevis] Champs de sceau interdits via updateDevis — utiliser applySignatureSeal');
        return {
          ok: false,
          reason: 'seal_fields_forbidden',
          userMessage: 'Ces champs ne peuvent pas être modifiés ici.',
        };
      }
      if (computedPatch.lignes && Array.isArray(computedPatch.lignes)) {
        const t = AF_DEVIS.calcFromLignes(computedPatch.lignes);
        computedPatch.totalHT = t.totalHT;
        computedPatch.totalTVA = t.totalTVA;
        computedPatch.totalTTC = t.totalTTC;
        computedPatch.acompte = t.acompte;
        computedPatch.amount = t.totalTTC;
        computedPatch.items = computedPatch.lignes.length;
      }
      if (computedPatch.status === 'signe' || computedPatch.statut === 'signe' || computedPatch.status === 'paye' || computedPatch.statut === 'paye') {
        delete computedPatch.status;
        delete computedPatch.statut;
      }
      if (computedPatch.status) computedPatch.statut = _normStatus(computedPatch);
      if (computedPatch.statut && !computedPatch.status) computedPatch.status = _normStatus(computedPatch);
      computedPatch.updatedAt = new Date().toISOString();
      let auditPatch = addQuoteAuditEvent({ auditTrail: curLocked.auditTrail || [] }, {
        type: _COMPLIANCE_EVENT_TYPES.UPDATED,
        actor: 'artisan',
        label: 'Devis mis à jour',
        metadata: { fields: Object.keys(updates || {}) },
      });
      if (customAuditEvent) {
        auditPatch = addQuoteAuditEvent(auditPatch, customAuditEvent);
      }

      if (userIdx !== -1) {
        const prevRaw = { ..._userQuotes[userIdx] };
        _userQuotes[userIdx] = { ..._userQuotes[userIdx], ...computedPatch, auditTrail: auditPatch.auditTrail };
        const merged = _normalizeDevis(_userQuotes[userIdx]);
        _userQuotes[userIdx] = { ...merged };
        if (_firebaseReady && _db) {
          _userQuotes[userIdx] = { ..._userQuotes[userIdx], localSync: _localSyncNow({ state: AF_LOCAL_SYNC.PENDING }) };
        } else {
          _userQuotes[userIdx] = {
            ..._userQuotes[userIdx],
            localSync: _localSyncNow({
              state: AF_LOCAL_SYNC.LOCAL_ONLY,
              detail: 'Modification en local (cloud non actif).',
            }),
          };
        }
        _saveToLS(_userQuotes);
        _notify();
        const fsRes = await _saveToFirestore(_userQuotes[userIdx], {});
        if (!fsRes.ok && !fsRes.skipped) {
          _userQuotes[userIdx] = prevRaw;
          _saveToLS(_userQuotes);
          _notify();
          return {
            ok: false,
            reason: 'firestore_write',
            userMessage: fsRes.userMessage || 'Sauvegarde cloud refusée ; modification annulée.',
          };
        }
        _userQuotes[userIdx] = { ..._userQuotes[userIdx], localSync: _localSyncMetaFromWriteResult(fsRes) };
        _saveToLS(_userQuotes);
        _notify();
        return { ok: true };
      }
      if (staticIdx !== -1 && _demoDataOn()) {
        const demoAudit = addQuoteAuditEvent({ auditTrail: AF_QUOTES[staticIdx].auditTrail || [] }, {
          type: _COMPLIANCE_EVENT_TYPES.UPDATED,
          actor: 'artisan',
          label: 'Devis mis à jour',
          metadata: { fields: Object.keys(updates || {}) },
        });
        Object.assign(AF_QUOTES[staticIdx], updates, { auditTrail: demoAudit.auditTrail });
        _notify();
        return { ok: true };
      }
      return { ok: false, reason: 'not_found' };
    },

    subscribe(fn) {
      _listeners.push(fn);
      return () => { _listeners = _listeners.filter((l) => l !== fn); };
    },

    async createQuoteVersion(sourceQuoteOrId) {
      const src = (sourceQuoteOrId && typeof sourceQuoteOrId === 'object')
        ? sourceQuoteOrId
        : this.getDevisById(sourceQuoteOrId);
      const versioned = createQuoteVersionPayload(src);
      if (!versioned) return { ok: false, reason: 'source_not_found' };
      const ownerUid = (await _ensureAuth()) || _getUidSync() || '';
      const toStore = {
        ...versioned,
        ownerUid: String(versioned.ownerUid || ownerUid),
      };
      if (_firebaseReady && _db) {
        toStore.localSync = _localSyncNow({ state: AF_LOCAL_SYNC.PENDING });
      } else {
        toStore.localSync = _localSyncNow({
          state: AF_LOCAL_SYNC.LOCAL_ONLY,
          detail: 'Nouvelle version créée en local (cloud non actif).',
        });
      }
      _userQuotes = [toStore, ..._userQuotes];
      _saveToLS(_userQuotes);
      _notify();
      const fsRes = await _saveToFirestore(toStore, {});
      toStore.localSync = _localSyncMetaFromWriteResult(fsRes);
      _saveToLS(_userQuotes);
      _notify();
      return { ok: true, quote: _normalizeDevis(toStore) };
    },

    async sendQuoteForSignature(sourceQuoteOrId) {
      const src = (sourceQuoteOrId && typeof sourceQuoteOrId === 'object')
        ? _normalizeDevis(sourceQuoteOrId)
        : this.getDevisById(sourceQuoteOrId);
      if (!src || !src.id) return { ok: false, reason: 'not_found', userMessage: 'Devis introuvable.' };
      if (!canSendQuote(src)) {
        return { ok: false, reason: 'cannot_send', userMessage: 'Ce devis ne peut pas être envoyé dans son état actuel.' };
      }

      const lines = Array.isArray(src.lignes)
        ? src.lignes
        : (Array.isArray(src.items) && src.items.length && typeof src.items[0] === 'object' ? src.items : []);
      if (!lines.length) {
        return {
          ok: false,
          reason: 'missing_lines',
          userMessage: 'Ajoutez au moins une prestation avant l’envoi pour signature.',
        };
      }

      const totalTTC = (src.totalTTC != null ? Number(src.totalTTC) : Number(src.amount));
      if (!Number.isFinite(totalTTC)) {
        return {
          ok: false,
          reason: 'missing_ttc',
          userMessage: 'Vérifiez le total TTC avant l’envoi pour signature.',
        };
      }

      const clientEmail = String(
        (src.client && typeof src.client === 'object' ? src.client.email : '')
        || src.clientEmail
        || src.emailClient
        || ''
      ).trim();
      if (!_isValidEmailLite(clientEmail)) {
        return {
          ok: false,
          reason: 'missing_client_email',
          userMessage: 'Ajoutez l’email du client pour envoyer le devis à signer.',
        };
      }

      const profile = getProfileForQuotePayload();
      if (!isProfileCompleteForSignature(profile)) {
        return {
          ok: false,
          reason: 'incomplete_profile',
          userMessage: 'Complétez votre profil artisan pour envoyer ce devis à signer.',
        };
      }
      const workspaceId = String(src.workspaceId || profile.workspaceId || getOrCreateWorkspaceId()).trim();
      const createdFromDeviceId = String(src.createdFromDeviceId || getOrCreateDeviceId()).trim();
      const quoteCompany = (src.company && typeof src.company === 'object') ? src.company : {};
      const quoteArtisan = (src.artisan && typeof src.artisan === 'object') ? src.artisan : {};
      const artisanEmail = String(
        src.artisanEmail
        || quoteArtisan.email
        || quoteCompany.email
        || profile.email
        || ''
      ).trim().toLowerCase();
      if (!_isValidEmailLite(artisanEmail)) {
        return {
          ok: false,
          reason: 'missing_artisan_email',
          userMessage: 'Complétez votre profil artisan pour envoyer ce devis à signer.',
        };
      }
      const companyName = String(
        quoteCompany.name
        || src.companyName
        || profile.companyName
        || src.artisanName
        || quoteArtisan.name
        || profile.name
        || ''
      ).trim();
      const companyPhone = String(quoteCompany.phone || profile.phone || '').trim();
      const companyAddress = String(quoteCompany.address || profile.address || src.clientAddress || '').trim();
      const artisanName = String(quoteArtisan.name || src.artisanName || profile.name || companyName || '').trim();

      const payload = {
        quoteId: String(src.id),
        workspaceId,
        createdFromDeviceId,
        quoteNumber: String(src.id || src.quoteNumber || '').trim(),
        company: {
          name: companyName,
          email: artisanEmail,
          phone: companyPhone,
          address: companyAddress,
        },
        artisan: {
          name: artisanName,
          email: artisanEmail,
        },
        client: {
          name: String(src.clientName || '').trim(),
          email: clientEmail,
          phone: String(src.clientPhone || '').trim(),
          address: String(src.clientAddress || '').trim(),
        },
        items: lines,
        lines: lines,
        totals: {
          ht: src.totalHT != null ? String(src.totalHT) : null,
          tva: src.totalTVA != null ? String(src.totalTVA) : null,
          ttc: String(totalTTC),
        },
        totalHT: src.totalHT != null ? String(src.totalHT) : null,
        totalTVA: src.totalTVA != null ? String(src.totalTVA) : null,
        totalTTC: String(totalTTC),
        amount: String(totalTTC),
        clientEmail: clientEmail,
        artisanEmail: artisanEmail,
        currency: String(src.currency || 'EUR'),
        status: 'draft',
        version: Number(src.version) || 1,
        createdAt: src.createdAtFull || src.createdAt || null,
        legalTerms: String(src.legalTerms || '').trim(),
        paymentTerms: String(src.paymentTerms || '').trim(),
        validityDate: src.validityDate || src.dueDate || null,
        notes: String(src.notes || '').trim(),
        metadata: {
          source: 'instantdevis_pwa',
          appVersion: _APP_VERSION,
        },
      };

      const rawBase = (typeof AF_WORKER !== 'undefined' && AF_WORKER._baseWorkers)
        ? AF_WORKER._baseWorkers()
        : '';
      const base = (typeof AF_WORKER !== 'undefined' && AF_WORKER._normalizeWorkerBase)
        ? AF_WORKER._normalizeWorkerBase(rawBase)
        : '';
      if (!base) {
        return { ok: false, reason: 'no_worker_base', userMessage: 'Le service de signature est temporairement indisponible.' };
      }

      const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
      const tid = ctrl ? setTimeout(() => ctrl.abort(), 30000) : null;
      let r;
      let body = null;
      try {
        r = await fetch(`${base}/v1/quotes/${encodeURIComponent(String(src.id))}/send-signature`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', ...(AF_WORKER._getOptionalClientHeaders ? AF_WORKER._getOptionalClientHeaders() : {}) },
          body: JSON.stringify(payload),
          ...(ctrl ? { signal: ctrl.signal } : {}),
        });
        try { body = await r.json(); } catch (e) { body = null; }
      } catch (e) {
        if (tid) clearTimeout(tid);
        return {
          ok: false,
          reason: 'network',
          userMessage: 'Impossible de contacter le service de signature. Vérifiez votre connexion.',
        };
      } finally {
        if (tid) clearTimeout(tid);
      }

      if (!r.ok) {
        if (r.status === 400) return { ok: false, reason: 'invalid_payload', userMessage: 'Le devis est incomplet ou invalide. Vérifiez le client, les lignes et le total.' };
        if (r.status === 409) return { ok: false, reason: 'conflict', userMessage: 'Ce devis ne peut pas être envoyé dans son état actuel.' };
        if (r.status === 503) return { ok: false, reason: 'unavailable', userMessage: 'Le service de signature est temporairement indisponible.' };
        return { ok: false, reason: 'request_failed', userMessage: 'Impossible d’envoyer le devis pour signature. Réessayez dans quelques instants.' };
      }
      if (!body || body.success !== true) {
        return { ok: false, reason: 'invalid_response', userMessage: 'Impossible d’envoyer le devis pour signature. Réessayez dans quelques instants.' };
      }

      const idx = _userQuotes.findIndex((q) => String(q.id) === String(src.id));
      if (idx === -1) {
        return { ok: false, reason: 'not_found_local', userMessage: 'Devis introuvable en local.' };
      }
      let updated = {
        ..._userQuotes[idx],
        workspaceId: workspaceId,
        createdFromDeviceId: createdFromDeviceId,
        status: 'sent',
        statut: 'envoye',
        sentAt: body.sentAt || new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        locked: false,
        legalSnapshot: {
          ...((_userQuotes[idx] && _userQuotes[idx].legalSnapshot && typeof _userQuotes[idx].legalSnapshot === 'object') ? _userQuotes[idx].legalSnapshot : {}),
          quoteHash: body.quoteHash || null,
          pdfHash: ((_userQuotes[idx] && _userQuotes[idx].legalSnapshot && _userQuotes[idx].legalSnapshot.pdfHash) ? _userQuotes[idx].legalSnapshot.pdfHash : null),
          lockedAt: null,
          lockedReason: 'sent_for_signature',
          sourceVersion: ((_userQuotes[idx] && _userQuotes[idx].legalSnapshot && _userQuotes[idx].legalSnapshot.sourceVersion) ? _userQuotes[idx].legalSnapshot.sourceVersion : null),
        },
        signatureRequest: {
          snapshotId: body.snapshotId || null,
          quoteHash: body.quoteHash || null,
          sentAt: body.sentAt || null,
          expiresAt: body.expiresAt || null,
          status: 'sent',
        },
      };
      updated = addQuoteAuditEvent(updated, {
        type: _COMPLIANCE_EVENT_TYPES.SENT_FOR_SIGNATURE,
        actor: 'artisan',
        label: 'Devis envoyé pour signature',
        metadata: {
          snapshotId: body.snapshotId || null,
          expiresAt: body.expiresAt || null,
        },
      });
      if (_firebaseReady && _db) {
        updated.localSync = _localSyncNow({ state: AF_LOCAL_SYNC.PENDING });
      } else {
        updated.localSync = _localSyncNow({
          state: AF_LOCAL_SYNC.LOCAL_ONLY,
          detail: 'Envoi pour signature enregistré en local (cloud non actif).',
        });
      }
      _userQuotes[idx] = _normalizeDevis(updated);
      _saveToLS(_userQuotes);
      _notify();
      const fsRes = await _saveToFirestore(_userQuotes[idx], {});
      _userQuotes[idx] = { ..._userQuotes[idx], localSync: _localSyncMetaFromWriteResult(fsRes) };
      _saveToLS(_userQuotes);
      _notify();

      return {
        ok: true,
        quote: _normalizeDevis(_userQuotes[idx]),
        snapshotId: body.snapshotId || null,
        quoteHash: body.quoteHash || null,
        sentAt: body.sentAt || null,
        expiresAt: body.expiresAt || null,
        status: body.status || 'sent',
      };
    },

    initFirestoreSync() {
      if (!_firebaseReady || !_db) {
        return () => {};
      }
      let stopped = false;
      let usub = null;
      (async () => {
        const uid = await _ensureAuth();
        if (stopped) return;
        if (!uid) {
          const msg = 'Connexion requise pour la synchronisation cloud (Firebase Auth : activez l’authentification anonyme ou un fournisseur).';
          _setLastFirestoreError(msg);
          return;
        }
        usub = _db
          .collection('devis')
          .where('ownerUid', '==', uid)
          .onSnapshot(
            (snap) => {
              if (stopped) return;
              const fromCloud = snap.docs.map((d) => _normalizeDevis(d.data()));
              const byId = new Map();
              _userQuotes.forEach((d) => {
                if (d && d.id != null) byId.set(String(d.id), d);
              });
              fromCloud.forEach((d) => {
                if (d && d.id != null) {
                  byId.set(String(d.id), {
                    ...d,
                    localSync: _localSyncNow({ state: AF_LOCAL_SYNC.SYNCED }),
                  });
                }
              });
              const merged = Array.from(byId.values());
              merged.sort((a, b) => {
                const ad = a.createdAtFull || a.createdAt || '';
                const bd = b.createdAtFull || b.createdAt || '';
                return String(bd).localeCompare(String(ad));
              });
              _userQuotes = merged;
              _saveToLS(_userQuotes);
              _notify();
            },
            (err) => {
              const u = _firestoreErrorToMessage(err);
              _setLastFirestoreError(u);
              console.warn('[InstantDevis] Firestore listen:', err && err.message);
            }
          );
      })();
      return () => {
        stopped = true;
        if (usub) {
          usub();
          usub = null;
        }
      };
    },

    getLastFirestoreError() {
      return _lastFirestoreError;
    },

    clearLastFirestoreError() {
      _lastFirestoreError = null;
    },

    /**
     * Présentation UI de `devis.localSync` (défini pour les devis **utilisateur** ; absent pour démo / anciennes données).
     * @returns {null|{ state: string, shortLabel: string, title: string, detail: string|null }}
     */
    localSyncPresentation(devis) {
      const ls = devis && devis.localSync;
      if (!ls || !ls.state) return null;
      const S = AF_LOCAL_SYNC;
      if (ls.state === S.SYNCED) {
        return {
          state: S.SYNCED,
          shortLabel: 'Cloud OK',
          title: 'Synchronisé avec le cloud',
          detail: null,
        };
      }
      if (ls.state === S.LOCAL_ONLY) {
        return {
          state: S.LOCAL_ONLY,
          shortLabel: 'Local seul',
          title: 'En local sur cet appareil',
          detail: ls.detail || null,
        };
      }
      if (ls.state === S.PENDING) {
        return {
          state: S.PENDING,
          shortLabel: 'Sync…',
          title: 'Synchronisation en cours',
          detail: ls.detail || null,
        };
      }
      if (ls.state === S.ERROR) {
        return {
          state: S.ERROR,
          shortLabel: 'Sync erreur',
          title: 'Erreur de synchronisation cloud',
          detail: ls.detail || null,
        };
      }
      return null;
    },

    computeStats(allQuotes) {
      const list = (allQuotes || []).map((q) => _normalizeDevis(q));
      const now = new Date();
      const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
      const nextMonthStart = new Date(now.getFullYear(), now.getMonth() + 1, 1);
      const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
      const ttc = (q) => Number(q.totalTTC != null ? q.totalTTC : q.amount) || 0;
      return {
        monthRevenue: list
          .filter((q) => (Number(q.paid) || 0) > 0 && new Date(q.createdAt) >= monthStart)
          .reduce((s, q) => s + (Number(q.paid) || 0), 0),
        monthTarget: (_demoDataOn() && typeof AF_STATS !== 'undefined' ? AF_STATS.monthTarget : 18000),
        pendingAmount: list
          .filter((q) => q.status === 'sent')
          .reduce((s, q) => s + ttc(q), 0),
        overdueAmount: list
          .filter((q) => q.status === 'retard')
          .reduce((s, q) => s + ttc(q), 0),
        overdueCount: list.filter((q) => q.status === 'retard').length,
        signedThisMonth: list.filter((q) => {
          if (q.status !== 'signe') return false;
          const ref = _signeReferenceDate(q);
          if (!ref) return false;
          return ref >= monthStart && ref < nextMonthStart;
        }).length,
        notesThisWeek: list.filter((q) => {
          if (q.source !== 'voice') return false;
          const d = q.createdAt ? new Date(q.createdAt) : null;
          if (!d || Number.isNaN(d.getTime())) return false;
          return d >= weekAgo;
        }).length,
      };
    },

    get isFirebaseReady() { return _firebaseReady; },
    get isAuthReady() { return !!_getUidSync(); },
    get isCloudSyncConfigured() { return !!(_firebaseReady && _db && _authHasModule); },
    getOrCreateWorkspaceId,
    getOrCreateDeviceId,
    normalizeProfile,
    loadProfile,
    saveProfile,
    isProfileCompleteForSignature,
    getProfileForQuotePayload,
    isQuoteLocked,
    canEditQuote,
    canSendQuote,
    canSignQuote,
    canDuplicateQuote,
    getQuoteLegalState,
    normalizeQuote,
    addQuoteAuditEvent,
    createQuoteVersionPayload,
  };
})();

Object.assign(window, {
  AF_STORE,
  AF_AUTH,
  AF_FIREBASE_CONFIG,
  AF_DEVIS,
  AF_WORKER,
  AF_LOCAL_SYNC,
  getOrCreateWorkspaceId,
  getOrCreateDeviceId,
  normalizeProfile,
  loadProfile,
  saveProfile,
  isProfileCompleteForSignature,
  getProfileForQuotePayload,
  isQuoteLocked,
  canEditQuote,
  canSendQuote,
  canSignQuote,
  canDuplicateQuote,
  getQuoteLegalState,
  normalizeQuote,
  addQuoteAuditEvent,
  createQuoteVersion: createQuoteVersionPayload,
});
