Your IP : 216.73.217.6


Current Path : /home/atelierpru/www/airvalue/
Upload File :
Current File : /home/atelierpru/www/airvalue/adm-flow.js

'use strict';

document.addEventListener('DOMContentLoaded', function () {

var BUILD = '20260804c';
var PROXY_KEY = 'Fefef430430@';
var PROXY = '/adm-api/proxy.php';
var GEOCODE_URL = 'https://api-adresse.data.gouv.fr/search/';

var selected = null;
var allResults = {};
var $stepContainer = null;

var $address      = document.getElementById('df-address');
var $dropdown     = document.getElementById('df-dropdown');
var $runBtn       = document.getElementById('df-run');
var $info         = document.getElementById('df-info');
var $steps        = document.getElementById('df-steps');
var $globalStatus = document.getElementById('df-status');
var $log          = document.getElementById('df-log');
var $logToggle    = document.getElementById('df-log-toggle');
var $nocache      = document.getElementById('df-nocache');
var $recent       = document.getElementById('df-recent');

if (!$address) return;

// Garde-fou de version : compare le build du JS (ce fichier) à celui attendu
// par le HTML. Les builds sont des dates → comparaison lexicale fiable.
// JS < HTML : vieux JS en cache navigateur → Ctrl+Shift+R.
// JS > HTML : vieux HTML servi (cache serveur/CDN ou déploiement en cours) —
//  le rafraîchissement navigateur n'y peut rien, c'est côté hébergeur.
function buildBadge(el) {
    if (!el) return;
    el.textContent = 'Build JS: ' + BUILD;
    var expected = (typeof window !== 'undefined' && window.__DF_EXPECTED_BUILD) || null;
    if (!expected || expected === BUILD) return;
    if (BUILD < expected) {
        el.textContent = 'Build JS: ' + BUILD + ' — JS PÉRIMÉ (attendu ' + expected + ') : Ctrl+Shift+R';
    } else {
        el.textContent = 'Build JS: ' + BUILD + ' — page HTML périmée (build ' + expected +
            ') : cache serveur/CDN ou déploiement en cours, réessayer dans ~1 min';
    }
    el.style.color = '#ef4444';
    el.style.fontWeight = '700';
}
buildBadge(document.getElementById('df-build'));
buildBadge(document.getElementById('mvp-build'));

function updateLogToggle() {
    if (!$logToggle || !$log) return;
    var n = $log.children.length;
    $logToggle.hidden = n === 0;
    $logToggle.textContent = ($log.classList.contains('open') ? 'Masquer les logs' : 'Afficher les logs') + ' (' + n + ')';
}

if ($logToggle && $log) {
    $logToggle.addEventListener('click', function () {
        $log.classList.toggle('open');
        updateLogToggle();
        if ($log.classList.contains('open')) $log.scrollTop = $log.scrollHeight;
    });
}

function logMsg(text, level) {
    if (!$log) return;
    var now = new Date();
    var ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
        .map(function (n) { return n < 10 ? '0' + n : '' + n; }).join(':');
    var colors = { info: '#3b82f6', ok: '#22c55e', error: '#ef4444', warn: '#f59e0b', run: '#a855f7' };
    var c = colors[level] || colors.info;
    var line = document.createElement('div');
    line.className = 'df-log-line';
    line.innerHTML = '<span class="df-log-ts">' + ts + '</span> <span style="color:' + c + '">●</span> ' + text;
    $log.appendChild(line);
    $log.scrollTop = $log.scrollHeight;
    updateLogToggle();
}

function clearLog() {
    if ($log) $log.innerHTML = '';
    updateLogToggle();
}

function timedFetch(url, opts, timeoutMs) {
    var controller = new AbortController();
    var timer = setTimeout(function () { controller.abort(); }, timeoutMs || 20000);
    opts = opts || {};
    opts.signal = controller.signal;
    return fetch(url, opts).finally(function () { clearTimeout(timer); });
}

var debounceTimer = null;
var activeIndex = -1;
var currentFeatures = [];

// Lignes non vides du champ unique (1 = analyse détaillée, >1 = batch).
function addressLines() {
    return $address.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
}

$address.addEventListener('input', function () {
    var lines = addressLines();
    selected = null;
    clearTimeout(debounceTimer);

    // Plusieurs lignes = mode batch : pas d'autocomplétion, bouton actif.
    if (lines.length > 1) {
        closeDropdown();
        $runBtn.disabled = false;
        updateRunBtnLabel();
        $info.textContent = Math.min(lines.length, 100) + ' adresse(s) — mode batch (les étapes détaillées ne seront pas affichées).';
        return;
    }

    var q = $address.value.trim();
    $runBtn.disabled = q.length < 3;
    updateRunBtnLabel();
    if (q.length < 3) {
        $info.textContent = 'Saisir au moins 3 caractères pour rechercher.';
        closeDropdown();
        return;
    }
    $info.textContent = 'Recherche en cours…';
    debounceTimer = setTimeout(function () { geocodeSearch(q); }, 300);
});

$address.addEventListener('keydown', function (e) {
    if (!$dropdown.classList.contains('open')) return;
    var items = $dropdown.querySelectorAll('.df-dd-item');
    if (e.key === 'ArrowDown') {
        e.preventDefault();
        activeIndex = Math.min(activeIndex + 1, items.length - 1);
        highlightItem(items);
    } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        activeIndex = Math.max(activeIndex - 1, 0);
        highlightItem(items);
    } else if (e.key === 'Enter') {
        e.preventDefault();
        if (activeIndex >= 0 && currentFeatures[activeIndex]) {
            selectFeature(currentFeatures[activeIndex]);
        }
    } else if (e.key === 'Escape') {
        closeDropdown();
    }
});

document.addEventListener('click', function (e) {
    if (!e.target.closest('#df-search-wrap')) closeDropdown();
});

function highlightItem(items) {
    items.forEach(function (el, i) { el.classList.toggle('active', i === activeIndex); });
    if (items[activeIndex]) items[activeIndex].scrollIntoView({ block: 'nearest' });
}

function closeDropdown() {
    $dropdown.classList.remove('open');
    $dropdown.innerHTML = '';
    activeIndex = -1;
}

async function geocodeSearch(query) {
    var url = GEOCODE_URL + '?q=' + encodeURIComponent(query) + '&limit=10';
    logMsg('GET <code>' + esc(url) + '</code>', 'info');
    $info.textContent = 'Recherche en cours…';

    try {
        var t0 = performance.now();
        var res = await timedFetch(url, {}, 15000);
        var dur = Math.round(performance.now() - t0);

        if (!res.ok) {
            var msg = 'Geocodage HTTP ' + res.status + ' (' + dur + 'ms)';
            $info.textContent = msg;
            logMsg(msg, 'error');
            return;
        }
        var json = await res.json();
        currentFeatures = json.features || [];
        if (currentFeatures.length === 0) {
            $info.textContent = '0 résultat pour "' + query + '" (' + dur + 'ms)';
            logMsg('0 résultat pour "' + esc(query) + '" (' + dur + 'ms)', 'warn');
        } else {
            $info.textContent = currentFeatures.length + ' résultat(s) — ' + dur + 'ms — cliquer pour sélectionner';
            logMsg(currentFeatures.length + ' résultat(s) en ' + dur + 'ms', 'ok');
        }
        renderDropdown(currentFeatures);
    } catch (err) {
        var errMsg = err.name === 'AbortError' ? 'Timeout (15s) — api-adresse.data.gouv.fr ne répond pas' : err.message;
        $info.textContent = 'Erreur geocodage : ' + errMsg;
        logMsg('ERREUR geocodage : ' + esc(errMsg), 'error');
        closeDropdown();
    }
}

// Ids BAN des adresses en cache local valide (TTL + build), avec leur date de
// mise en cache. Lecture du seul début de chaque entrée localStorage (savedAt
// et build sont sérialisés en premier) pour éviter de parser des Mo de JSON à
// chaque frappe.
function cachedAddressIds() {
    var ids = {};
    var now = Date.now();
    try {
        for (var i = 0; i < localStorage.length; i++) {
            var k = localStorage.key(i);
            if (!k || k.indexOf(LOCAL_CACHE_PREFIX) !== 0) continue;
            var head = (localStorage.getItem(k) || '').slice(0, 200);
            var mAt = head.match(/"savedAt":(\d+)/);
            var mBuild = head.match(/"build":"([^"]*)"/);
            var savedAt = mAt ? parseInt(mAt[1], 10) : 0;
            if (savedAt && (now - savedAt) < LOCAL_CACHE_TTL_MS && mBuild && mBuild[1] === BUILD) {
                ids[k.slice(LOCAL_CACHE_PREFIX.length)] = savedAt;
            }
        }
    } catch (e) { /* localStorage indisponible */ }
    return ids;
}

function renderDropdown(features) {
    if (!features.length) { closeDropdown(); return; }
    var cached = cachedAddressIds();
    var cachedAt = function (f) {
        var id = f.properties && f.properties.id;
        return id && cached[id] ? cached[id] : 0;
    };

    // 2 = Paris, 1 = Île-de-France, 0 = reste, d'après le département INSEE.
    var IDF_DEPTS = { 75: 1, 77: 1, 78: 1, 91: 1, 92: 1, 93: 1, 94: 1, 95: 1 };
    var geoScore = function (f) {
        var dept = String((f.properties && f.properties.citycode) || '').slice(0, 2);
        if (dept === '75') return 2;
        return IDF_DEPTS[dept] ? 1 : 0;
    };

    // Priorité : adresses en cache, puis Paris, puis IDF ; ordre BAN sinon.
    var decorated = features.map(function (f, i) { return { f: f, i: i }; });
    decorated.sort(function (a, b) {
        var ca = cachedAt(a.f) ? 1 : 0, cb = cachedAt(b.f) ? 1 : 0;
        return cb - ca || geoScore(b.f) - geoScore(a.f) || a.i - b.i;
    });
    features.length = 0;
    decorated.forEach(function (d) { features.push(d.f); });

    var nbCached = features.filter(function (f) { return cachedAt(f) > 0; }).length;
    if (nbCached) {
        $info.textContent = features.length + ' résultat(s) — dont ' + nbCached + ' déjà en cache — cliquer pour sélectionner';
    }

    $dropdown.innerHTML = features.map(function (f, i) {
        var p = f.properties || {};
        var label = esc(p.label || '');
        var at = cachedAt(f);
        var badge = at
            ? '<span class="df-dd-badge">en cache · ' + esc(new Date(at).toLocaleDateString('fr-FR')) + '</span>'
            : '';
        var ctx = esc(p.context || ([p.postcode, p.city].filter(Boolean).join(' ')));
        return '<div class="df-dd-item" data-idx="' + i + '">' +
            label + badge + '<span class="df-dd-sub">' + ctx + ' — INSEE ' + esc(p.citycode || '?') + '</span></div>';
    }).join('');
    $dropdown.classList.add('open');
    activeIndex = -1;

    $dropdown.querySelectorAll('.df-dd-item').forEach(function (el) {
        el.addEventListener('click', function () {
            selectFeature(currentFeatures[parseInt(el.dataset.idx, 10)]);
        });
    });
}

function selectFeature(feature) {
    var p = feature.properties || {};
    var coords = feature.geometry.coordinates;

    selected = {
        lat: coords[1], lon: coords[0],
        insee: p.citycode || '', commune: p.city || '', label: p.label || '',
        _feature: feature,
    };

    $address.value = selected.label;
    closeDropdown();
    $runBtn.disabled = false;
    var fromCache = p.id && cachedAddressIds()[p.id];
    $info.innerHTML =
        '<strong style="color:#22c55e">&#10003;</strong> ' +
        '<code class="text-xs">' + esc(selected.label) + '</code> — ' +
        'lat=<code class="text-xs">' + selected.lat + '</code> lon=<code class="text-xs">' + selected.lon + '</code> ' +
        'insee=<code class="text-xs">' + esc(selected.insee) + '</code>' +
        (fromCache
            ? ' — <span style="color:#15803d;font-weight:600">&#9889; adresse déjà en cache (' + esc(new Date(fromCache).toLocaleDateString('fr-FR')) + ') : résultats instantanés, aucun appel API</span>'
            : '');
    updateRunBtnLabel();
    logMsg('Adresse sélectionnée : <strong>' + esc(selected.label) + '</strong> (INSEE ' + esc(selected.insee) + ')', 'ok');
}

var STEPS = [
    { num: 3, label: 'Identification parcelle',
      build: function (ctx) { return { action: 'parcels', params: { lat: ctx.lat, lon: ctx.lon } }; },
      extract: function (data, ctx) { var p = firstParcel(data); if (p) { ctx.parcelId = p.id || p.parcelId || p.idu || ''; ctx.parcelSurface = p.surface != null ? p.surface : (p.contenance != null ? p.contenance : null); } },
      summary: function (r, ctx) { return [kv('parcelId', ctx.parcelId || '—'), ctx.parcelSurface != null ? kv('surface', ctx.parcelSurface + ' m²') : '']; } },
    { num: 4, label: 'Détail parcelle',
      build: function (ctx) { return { action: 'parcel_detail', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      extract: function (data, ctx) { ctx.neighbors = (data && Array.isArray(data.neighbors)) ? data.neighbors.filter(Boolean) : []; },
      summary: function (r, ctx) { return [countItems(r.data) + ' élément(s) — ' + byteSize(r) + ' bytes', (ctx.neighbors && ctx.neighbors.length) ? kv('parcelles mitoyennes', ctx.neighbors.length) : '']; } },
    { num: 5, label: 'Coordonnées parcelle',
      build: function (ctx) { return { action: 'parcel_coords', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      summary: function (r) { return [byteSize(r) + ' bytes de géométrie']; } },
    { num: 6, label: 'Zones urbaines commune',
      build: function (ctx) { return { action: 'urban_zones', params: { insee: ctx.insee } }; },
      summary: function (r) { return [countItems(r.data) + ' zone(s)']; } },
    { num: 7, label: 'Unité foncière complète',
      build: function (ctx) { return { action: 'parcel_units', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      extract: function (data, ctx) { var z = extractUrbanZone(data); if (z) { ctx.zoneId = z.id || ''; ctx.zoneName = z.name || ''; ctx.zoneLibelong = z.libelong || ''; ctx.zoneType = z.type || 'PLU'; } },
      summary: function (r, ctx) { return [kv('zoneId', ctx.zoneId || '—'), kv('zoneName', ctx.zoneName || '—'), kv('libelong', ctx.zoneLibelong || '—')]; } },
    { num: 8, label: 'Règlement PLU Airtable',
      build: function (ctx) { return { action: 'airtable_plu', method: 'POST', params: { zoneId: ctx.zoneId }, body: { urbanZoneParams: { id: ctx.zoneId, name: ctx.zoneName, libelong: ctx.zoneLibelong, type: ctx.zoneType || 'PLU' } } }; }, requires: 'zoneId',
      summary: function (r) { return [countItems(r.data) + ' enregistrement(s) PLU']; } },
    { num: 9, label: 'Droits à construire',
      build: function (ctx) { return { action: 'building_rights', params: { zoneId: ctx.zoneId } }; }, requires: 'zoneId',
      summary: function (r) { return [countItems(r.data) + ' règle(s)']; } },
    { num: 10, label: 'PSC commune',
      build: function (ctx) { return { action: 'town_psc', params: { insee: ctx.insee } }; },
      summary: function (r) { return [byteSize(r) + ' bytes']; } },
    { num: 11, label: 'Type doc urbanisme',
      build: function (ctx) { return { action: 'docurba', params: { insee: ctx.insee } }; },
      summary: function (r) { return [describeShort(r.data)]; } },
    { num: 12, label: 'Info PLU GPU',
      build: function (ctx) { return { action: 'gpu_plu_info', params: { insee: ctx.insee } }; },
      summary: function (r) { return [describeShort(r.data)]; } },
    { num: 13, label: 'PSMV parcelle',
      build: function (ctx) { return { action: 'psmv', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      summary: function (r) { return [countItems(r.data) + ' PSMV']; } },
    { num: 14, label: 'District / arrondissement',
      build: function (ctx) { return { action: 'districts', params: { insee: ctx.insee } }; },
      summary: function (r) { return [describeShort(r.data)]; } },
    { num: 15, label: 'Stats DVF commune',
      build: function (ctx) { return { action: 'dvf_stats', params: { insee: ctx.insee } }; },
      summary: function (r) { return [byteSize(r) + ' bytes de stats']; } },
    { num: 16, label: 'Séries DVF commune',
      build: function (ctx) { return { action: 'dvf_series', params: { insee: ctx.insee } }; },
      summary: function (r) { return [countItems(r.data) + ' point(s) de série']; } },
    { num: 17, label: 'Ventes DVF parcelle',
      build: function (ctx) { return { action: 'dvf_parcels', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      summary: function (r) { return [countItems(r.data) + ' vente(s)']; } },
    { num: 18, label: 'Loyers de référence',
      build: function (ctx) { return { action: 'loyers', params: { insee: ctx.insee } }; },
      summary: function (r) { return [byteSize(r) + ' bytes']; } },
    { num: 19, label: 'ADU totaux commune',
      build: function (ctx) { return { action: 'adu_totals', params: { insee: ctx.insee } }; },
      summary: function (r) { return [describeShort(r.data)]; } },
    { num: 20, label: 'ADU parcelle',
      build: function (ctx) { return { action: 'adu_parcels', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      summary: function (r) { return [countItems(r.data) + ' enregistrement(s)']; } },
    { num: 21, label: 'ADU commune GeoJSON',
      build: function (ctx) { return { action: 'adu_insee', params: { insee: ctx.insee } }; },
      summary: function (r) { return [byteSize(r) + ' bytes GeoJSON']; } },
    { num: 22, label: 'Plans prévention risques',
      build: function (ctx) { return { action: 'prevention_plans', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
      summary: function (r) { return [countItems(r.data) + ' plan(s)']; } },
    { num: 23, label: 'Géorisques',
      build: function (ctx) { return { action: 'georisques', params: { lon: ctx.lon, lat: ctx.lat, insee: ctx.insee } }; },
      summary: function (r) { return [byteSize(r) + ' bytes de risques']; } },
    { num: 24, label: 'Analytics (tracking)',
      build: function (ctx) { return { action: 'analytics', method: 'POST', body: { user: ctx.userId || '', input: ctx.label || '', codeInsee: ctx.insee || '', nomCommune: ctx.commune || '' } }; },
      summary: function () { return ['événement envoyé']; } },
    { num: 25, label: 'Search count',
      build: function () { return { action: 'search_count', method: 'POST', body: null }; },
      summary: function (r) { return [describeShort(r.data)]; } },
];

// ---- Cache local par adresse ------------------------------------------------
// Un flow complet réussi est stocké dans localStorage, indexé par l'id BAN de
// l'adresse. Une adresse déjà cherchée est restaurée instantanément sans aucun
// appel API (ni analytics ni search_count — pas de quota iudo consommé).
// Invalidé après 7 jours, à chaque changement de build, ou via la case
// « Ignorer le cache ». Le proxy PHP a en plus son propre cache serveur.

var LOCAL_CACHE_PREFIX = 'dfcache:v1:';
var LOCAL_CACHE_TTL_MS = 7 * 24 * 3600 * 1000;

function cacheKeyFor(sel) {
    var banId = (sel._feature && sel._feature.properties && sel._feature.properties.id) || sel.label;
    return LOCAL_CACHE_PREFIX + banId;
}

function loadCache(sel) {
    try {
        var raw = localStorage.getItem(cacheKeyFor(sel));
        if (!raw) return null;
        var entry = JSON.parse(raw);
        var stale = !entry || !entry.results || entry.build !== BUILD
            || (Date.now() - (entry.savedAt || 0)) > LOCAL_CACHE_TTL_MS;
        if (stale) {
            localStorage.removeItem(cacheKeyFor(sel));
            return null;
        }
        return entry;
    } catch (e) { return null; }
}

function evictOldestCache() {
    var oldestKey = null, oldestAt = Infinity;
    for (var i = 0; i < localStorage.length; i++) {
        var k = localStorage.key(i);
        if (!k || k.indexOf(LOCAL_CACHE_PREFIX) !== 0) continue;
        var at = 0;
        try { at = (JSON.parse(localStorage.getItem(k)) || {}).savedAt || 0; } catch (e) {}
        if (at < oldestAt) { oldestAt = at; oldestKey = k; }
    }
    if (oldestKey) { localStorage.removeItem(oldestKey); return true; }
    return false;
}

function saveCache(sel, results) {
    var entry;
    try {
        entry = JSON.stringify({ savedAt: Date.now(), build: BUILD, label: sel.label, results: results });
    } catch (e) { return false; }
    for (var attempt = 0; attempt < 5; attempt++) {
        try { localStorage.setItem(cacheKeyFor(sel), entry); return true; }
        catch (e) { if (!evictOldestCache()) return false; }
    }
    return false;
}

// Libellé du bouton : « Afficher (cache) » quand l'adresse sélectionnée a un
// flow en cache local et que « Ignorer le cache » n'est pas coché.
function updateRunBtnLabel() {
    if (!$runBtn) return;
    var lines = $address ? addressLines() : [];
    if (lines.length > 1) {
        $runBtn.textContent = 'Lancer le batch (' + Math.min(lines.length, 100) + ')';
        return;
    }
    var live = $nocache && $nocache.checked;
    var banId = selected && selected._feature && selected._feature.properties && selected._feature.properties.id;
    var cachedTs = !live && banId ? cachedAddressIds()[banId] : 0;
    $runBtn.textContent = cachedTs ? 'Afficher (cache)' : 'Lancer';
}

if ($nocache) $nocache.addEventListener('change', updateRunBtnLabel);

// ---- Dernières recherches (historique serveur, partagé) ---------------------
// La box liste les 5 dernières adresses analysées depuis l'historique SERVEUR
// (partagé entre postes, persistant, indépendant du build front). Fallback sur
// l'historique local si le serveur est injoignable. Un clic rejoue le flow :
// instantané si l'adresse est en cache local, sinon relancé (le cache serveur
// accélère alors chaque step).

var recentOpen = false;
var recentItems = [];

// Historique local (fallback) — lu par tranches d'en-tête du cache localStorage.
function recentLocalEntries() {
    var list = [];
    var now = Date.now();
    try {
        for (var i = 0; i < localStorage.length; i++) {
            var k = localStorage.key(i);
            if (!k || k.indexOf(LOCAL_CACHE_PREFIX) !== 0) continue;
            var head = (localStorage.getItem(k) || '').slice(0, 400);
            var mAt = head.match(/"savedAt":(\d+)/);
            var mBuild = head.match(/"build":"([^"]*)"/);
            var mLabel = head.match(/"label":"((?:[^"\\]|\\.)*)"/);
            var savedAt = mAt ? parseInt(mAt[1], 10) : 0;
            if (!savedAt || (now - savedAt) >= LOCAL_CACHE_TTL_MS || !mBuild || mBuild[1] !== BUILD) continue;
            var label = mLabel ? mLabel[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : k.slice(LOCAL_CACHE_PREFIX.length);
            list.push({ key: k, banId: k.slice(LOCAL_CACHE_PREFIX.length), savedAt: savedAt, label: label, source: 'local' });
        }
    } catch (e) { /* localStorage indisponible */ }
    list.sort(function (a, b) { return b.savedAt - a.savedAt; });
    return list;
}

function fetchServerHistory() {
    return timedFetch(PROXY + '?action=history', { headers: { 'X-Admin-Key': PROXY_KEY } }, 8000)
        .then(function (res) { return res.json(); })
        .then(function (j) { return (j && j.ok && Array.isArray(j.entries)) ? j.entries : null; })
        .catch(function () { return null; });
}

// Envoie l'adresse analysée à l'historique serveur (partagé). Best-effort.
function postHistory(sel) {
    if (!sel || !sel._feature) return;
    var p = sel._feature.properties || {};
    var body = JSON.stringify({
        banId: p.id || sel.label, label: sel.label, insee: sel.insee, feature: sel._feature,
    });
    timedFetch(PROXY + '?action=history_add', {
        method: 'POST', headers: { 'X-Admin-Key': PROXY_KEY, 'Content-Type': 'application/json' }, body: body,
    }, 8000).then(function () { renderRecent(); }).catch(function () { /* best-effort */ });
}

// Date + heure d'une recherche. savedAt est en ms epoch ; on tolère les
// anciennes entrées mal formées (secondes, chaîne, valeur invalide).
function formatHistDate(v) {
    var n = typeof v === 'string' ? parseInt(v, 10) : v;
    if (!n || isNaN(n)) return '';
    if (n < 1e12) n *= 1000; // savedAt en secondes → ms
    var d = new Date(n);
    if (isNaN(d.getTime())) return '';
    // Garde-fou : d'anciennes entrées avaient un timestamp corrompu (overflow
    // 32 bits → dates ~1904). On n'affiche que des dates plausibles.
    var y = d.getFullYear();
    if (y < 2020 || y > 2100) return '';
    return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}

function renderRecent() {
    if (!$recent) return;
    fetchServerHistory().then(function (server) {
        var entries;
        if (server) {
            entries = server.slice(0, 5).map(function (e) {
                return { label: e.label || e.banId, savedAt: e.savedAt, feature: e.feature, banId: e.banId, source: 'server' };
            });
        } else {
            entries = recentLocalEntries().slice(0, 5);
        }
        recentItems = entries;

        if (!entries.length) { $recent.hidden = true; $recent.innerHTML = ''; return; }
        $recent.hidden = false;
        $recent.className = recentOpen ? 'open' : '';
        $recent.innerHTML =
            '<div class="df-recent-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<span>Dernières recherches (' + entries.length + ')' + (server ? '' : ' · local') + '</span></div>' +
            '<div class="df-recent-list">' +
            entries.map(function (e, i) {
                return '<button type="button" class="df-recent-item" data-idx="' + i + '">' +
                    '<span class="df-recent-icon">&#9889;</span>' +
                    '<span class="df-recent-label">' + esc(e.label) + '</span>' +
                    '<span class="df-recent-date">' + esc(formatHistDate(e.savedAt)) + '</span>' +
                    '</button>';
            }).join('') + '</div>';

        $recent.querySelector('.df-recent-head').addEventListener('click', function () {
            recentOpen = !recentOpen;
            $recent.className = recentOpen ? 'open' : '';
        });
        $recent.querySelectorAll('.df-recent-item').forEach(function (el) {
            el.addEventListener('click', function () { openRecent(recentItems[parseInt(el.getAttribute('data-idx'), 10)]); });
        });
    });
}

function openRecent(entry) {
    if (!entry) return;
    if (entry.source === 'local' && entry.key) { openFromCache(entry.key); return; }
    var feat = entry.feature;
    if (!feat || !feat.geometry) {
        logMsg('Entrée d\'historique sans géodonnées — saisir l\'adresse pour relancer', 'warn');
        return;
    }
    var p = feat.properties || {};
    var coords = feat.geometry.coordinates || [];
    selected = {
        lat: coords[1], lon: coords[0],
        insee: p.citycode || entry.insee || '', commune: p.city || '', label: p.label || entry.label || '',
        _feature: feat,
    };
    $address.value = selected.label;
    closeDropdown();
    $runBtn.disabled = false;
    updateRunBtnLabel();
    var local = loadCache(selected);
    if (local) replayFromCache(local); else runFlow();
    if ($steps && $steps.scrollIntoView) $steps.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

// Reconstruit `selected` depuis une entrée de cache LOCAL et rejoue (fallback).
function openFromCache(key) {
    var raw, entry;
    try { raw = localStorage.getItem(key); entry = raw ? JSON.parse(raw) : null; } catch (e) { entry = null; }
    var feat = entry && entry.results && entry.results.step_1_geocode && entry.results.step_1_geocode.data;
    if (!feat || !feat.geometry) {
        logMsg('Entrée de cache illisible — relancez une recherche', 'error');
        try { localStorage.removeItem(key); } catch (x) {}
        renderRecent();
        return;
    }
    var p = feat.properties || {};
    var coords = feat.geometry.coordinates || [];
    selected = {
        lat: coords[1], lon: coords[0],
        insee: p.citycode || '', commune: p.city || '', label: p.label || '',
        _feature: feat,
    };
    $address.value = selected.label;
    closeDropdown();
    $runBtn.disabled = false;
    updateRunBtnLabel();
    replayFromCache(entry);
    if ($steps && $steps.scrollIntoView) $steps.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

// Force un run en direct (ignore les caches local ET serveur) pour ce run
// uniquement, sans toucher à la case « Ignorer le cache ».
var forceLiveOnce = false;

var LOGIN_STEP = {
    num: 2, label: 'Authentification API', action: 'login',
    extract: function (data, ctx) {
        if (data && typeof data === 'object') {
            ctx.userId = data.userId || data._id || data.id || (data.user && data.user._id) || '';
            ctx.userStatus = data.status || data.subscriptionStatus || (data.user && data.user.status) || '';
        }
    },
    summary: function (r, ctx) { return [kv('userId', ctx.userId || '—'), kv('status', ctx.userStatus || '—')]; },
};

function replayStep(def, resp, ctx) {
    var spec = null;
    try { spec = def.build ? def.build(ctx) : { action: def.action, params: {} }; } catch (e) {}
    var url = spec ? buildDisplayUrl(spec.action, spec.params || {}, spec.method || 'GET') : '';
    var box = renderStep(def.num, def.label, url);

    if (!resp) {
        setDot(box, 'error');
        setMeta(box, 'absent du cache');
        box.classList.add('open');
        box.querySelector('.df-step-body').insertAdjacentHTML('beforeend',
            '<div class="df-step-error"><strong>Absent du cache</strong></div>');
        return false;
    }
    if (resp.ok) {
        setDot(box, 'ok');
        setMeta(box, 'HTTP ' + resp.status + ' · cache local');
        if (def.extract) { try { def.extract(resp.data, ctx); } catch (e) { /* ignore */ } }
        var lines = def.summary ? def.summary(resp, ctx).filter(Boolean) : [];
        renderSummary(box, resp.status, 0, lines);
    } else {
        setDot(box, 'error');
        setMeta(box, resp.status ? 'HTTP ' + resp.status : 'erreur');
        box.classList.add('open');
        renderError(box, resp, 0);
    }
    renderRawToggle(box, resp);
    return !!resp.ok;
}

function replayFromCache(entry) {
    $steps.innerHTML = '';
    $stepContainer = $steps;
    currentGroup = null;
    clearLog();
    Object.keys(allResults).forEach(function (k) { delete allResults[k]; });
    Object.assign(allResults, entry.results);

    var savedDate = new Date(entry.savedAt).toLocaleString('fr-FR');
    setGlobalStatus('Restauré du cache (' + savedDate + ')', 'ok');
    logMsg('<strong>Adresse déjà analysée le ' + esc(savedDate) + '</strong> — restauration depuis le cache local, aucun appel API. '
        + 'Cocher « Ignorer le cache » pour rejouer le flow.', 'ok');

    var banner = document.createElement('div');
    banner.className = 'df-cache-banner';
    banner.innerHTML =
        '<div>&#9889; <strong>Résultats issus du cache local</strong> — adresse analysée le ' + esc(savedDate) +
        '. Aucun appel API n\'a été effectué.</div>' +
        '<button type="button">Relancer en direct</button>';
    banner.querySelector('button').addEventListener('click', function () {
        forceLiveOnce = true;
        runFlow();
    });
    $steps.appendChild(banner);

    var ctx = { lat: selected.lat, lon: selected.lon, insee: selected.insee, commune: selected.commune, label: selected.label };

    minimapBuilt = false;
    renderMinimap();
    renderLegend();
    renderGeocodeStep();
    setChantierStatus('Restauré du cache', true);
    replayStep(LOGIN_STEP, allResults.step_2_login, ctx);
    STEPS.forEach(function (def) {
        if (def.num === GROUP_FROM) startStepGroup();
        var prefix = 'step_' + def.num + '_';
        var key = Object.keys(allResults).find(function (k) { return k.indexOf(prefix) === 0; });
        groupNote(replayStep(def, key ? allResults[key] : null, ctx));
    });
    endStepGroup();
    renderMitoyensReplay();

    updateMinimapBuilding();

    renderPocSteps();
    renderOutputStep();
    renderSurelevationStep();
    renderSurelevationReportStep();
    updateRunBtnLabel();
    postHistory(selected);
}

// Reconstruit la box mitoyens depuis les données en cache (sans re-fetch).
function renderMitoyensReplay() {
    var step = allResults.step_25b_mitoyens;
    var mitoyens = (step && Array.isArray(step.data)) ? step.data : [];
    var box = renderStep('25b', 'Immeubles mitoyens', mitoyens.length + ' parcelle(s) voisine(s)');
    setDot(box, 'ok');
    setMeta(box, mitoyens.length + ' mitoyen(s) · cache local');
    var withH = mitoyens.filter(function (m) { return m.hauteur_m != null; });
    var lines = mitoyens.length
        ? mitoyens.map(function (m) {
            return kv(m.parcelId, m.hauteur_m != null ? m.hauteur_m + ' m · ' + (m.niveaux != null ? m.niveaux + ' niv.' : '?') : (m.error || 'sans bâti'));
          })
        : ['Aucune parcelle mitoyenne'];
    if (withH.length > 1) lines.push(kv('moyenne hauteurs', avgHeight(withH) + ' m'));
    renderSummary(box, 200, 0, lines);
}

// ---- Sources nationales (switches de l'onglet API) --------------------------
// Quand un switch est actif, l'étape POC v0 correspondante appelle notre API
// nationale (action mvp_*) à la place d'iudo. Défaut : tout OFF = iudo.
var nationalSources = {};

function nationalActive() {
    for (var k in nationalSources) { if (nationalSources[k]) return true; }
    return false;
}

function fetchSources() {
    return timedFetch(PROXY + '?action=sources_get', { headers: { 'X-Admin-Key': PROXY_KEY } }, 5000)
        .then(function (res) { return res.json(); })
        .then(function (j) {
            if (j && j.ok && Array.isArray(j.sources)) {
                nationalSources = {};
                j.sources.forEach(function (s) { nationalSources[s.name] = !!s.enabled; });
            }
            return nationalSources;
        })
        .catch(function () { return nationalSources; });
}

// Remplacements iudo -> national, par action iudo. Chaque swap fournit son
// build (lat/lon au lieu des ids iudo) et son extract adapté au schéma national.
var NATIONAL_SWAPS = {
    parcels: {
        flag: 'parcelle_cadastre',
        build: function (ctx) { return { action: 'mvp_parcelle', params: { lat: ctx.lat, lon: ctx.lon } }; },
        extract: function (data, ctx) {
            var f = data && Array.isArray(data.features) && data.features[0];
            if (!f || !f.properties) return;
            var p = f.properties;
            ctx.parcelId = p.idu || [p.code_dep, p.code_com, p.com_abs || '000', p.section, p.numero].join('');
            ctx.parcelSurface = p.contenance != null ? p.contenance : null;
        },
    },
    urban_zones: {
        flag: 'zone_plu',
        build: function (ctx) { return { action: 'mvp_zone_urba', params: { lat: ctx.lat, lon: ctx.lon } }; },
        extract: null,
    },
    psmv: {
        flag: 'servitudes',
        build: function (ctx) { return { action: 'mvp_servitudes', params: { lat: ctx.lat, lon: ctx.lon } }; },
        extract: null,
    },
};

// Applique le swap éventuel à une étape : renvoie { spec, extract, label, swapped }.
function applyNationalSwap(def, spec, ctx) {
    var swap = NATIONAL_SWAPS[spec.action];
    if (!swap || !nationalSources[swap.flag]) {
        return { spec: spec, extract: def.extract, summary: def.summary, label: def.label, swapped: false };
    }
    return { spec: swap.build(ctx), extract: swap.extract, summary: null, label: def.label + ' · API nationale', swapped: true };
}

// Point d'entrée unique : 1 adresse → flow détaillé, plusieurs → batch.
async function runFlowOrBatch() {
    var lines = addressLines();
    if (!lines.length) return;
    if (lines.length > 1) {
        $steps.innerHTML = '';
        clearLog();
        setGlobalStatus('Batch en cours…', 'running');
        await runBatch();
        setGlobalStatus('Batch terminé', 'ok');
        return;
    }
    // Nettoie un éventuel batch précédent avant une analyse simple.
    batchRows = [];
    renderBatchTable();
    if ($batchProgress) $batchProgress.textContent = '';
    if ($batchCompile) $batchCompile.hidden = true;
    if (!selected) {
        // Adresse tapée sans passer par les suggestions : géocodage direct.
        $info.textContent = 'Géocodage de l\'adresse…';
        $runBtn.disabled = true;
        var feat = await geocodeOne(lines[0]);
        $runBtn.disabled = false;
        if (!feat || !feat.geometry) {
            $info.textContent = 'Adresse introuvable — précisez la saisie ou choisissez une suggestion.';
            return;
        }
        var p = feat.properties || {};
        selected = {
            lat: feat.geometry.coordinates[1], lon: feat.geometry.coordinates[0],
            insee: p.citycode || '', commune: p.city || '', label: p.label || lines[0], _feature: feat,
        };
        $info.textContent = '✓ ' + selected.label;
    }
    runFlow();
}

$runBtn.addEventListener('click', runFlowOrBatch);
renderRecent();
fetchSources();

async function runFlow() {
    if (!selected) return;
    $runBtn.disabled = true;
    $stepContainer = $steps;
    currentGroup = null;

    // Sources nationales à jour AVANT la décision de cache : un flow mixte
    // iudo/national ne doit pas être servi ni enregistré depuis le cache local.
    await fetchSources();
    var skipCache = forceLiveOnce || ($nocache && $nocache.checked) || nationalActive();
    if (!skipCache) {
        var cachedEntry = loadCache(selected);
        if (cachedEntry) {
            replayFromCache(cachedEntry);
            $runBtn.disabled = false;
            return;
        }
    }

    $steps.innerHTML = '';
    clearLog();
    Object.keys(allResults).forEach(function (k) { delete allResults[k]; });
    setGlobalStatus('En cours…', 'running');

    var ctx = { lat: selected.lat, lon: selected.lon, insee: selected.insee, commune: selected.commune, label: selected.label };
    var hadError = false;

    logMsg('<strong>Lancement du flow pour</strong> ' + esc(selected.label), 'run');

    minimapBuilt = false;
    renderMinimap();
    renderLegend();
    renderGeocodeStep();
    setChantierStatus('Géocodage de l\'adresse…');

    logMsg('Test connexion proxy (ping)… [JS build ' + BUILD + ']', 'info');
    try {
        var pingRes = await timedFetch(PROXY + '?action=ping', { headers: { 'X-Admin-Key': PROXY_KEY } }, 10000);
        var pingText = await pingRes.text();
        logMsg('Ping HTTP ' + pingRes.status + ' — <code>' + esc(pingText.slice(0, 500)) + '</code>', pingRes.ok ? 'ok' : 'error');
        if (pingRes.ok) {
            try { var pj = JSON.parse(pingText); if (pj.build) logMsg('PHP build: ' + esc(pj.build) + ' — PHP ' + esc(pj.php), 'ok'); } catch(e) {}
        }
    } catch (pingErr) {
        logMsg('Ping ERREUR: ' + esc(pingErr.message), 'error');
    }

    logMsg('STEP 2 — Authentification API…', 'info');
    setChantierStatus(LOGIN_STEP.label + '…');
    await sleep(STEP_MIN_MS);
    var loginResp = await runStep({
        num: LOGIN_STEP.num, label: LOGIN_STEP.label, action: LOGIN_STEP.action,
        extract: LOGIN_STEP.extract, summary: LOGIN_STEP.summary,
    }, ctx);
    if (!loginResp || !loginResp.ok) hadError = true;

    for (var i = 0; i < STEPS.length; i++) {
        var def = STEPS[i];
        if (def.num === GROUP_FROM) startStepGroup();
        if (def.requires && !ctx[def.requires]) {
            logMsg('STEP ' + def.num + ' — ' + esc(def.label) + ' : <span style="color:#ef4444">IGNORÉ</span> (prérequis manquant : ' + esc(def.requires) + ')', 'warn');
            renderSkippedStep(def, 'Prérequis manquant : ' + def.requires);
            allResults['step_' + def.num] = { skipped: true, reason: def.requires };
            groupNote(false);
            hadError = true;
            continue;
        }
        logMsg('STEP ' + def.num + ' — ' + esc(def.label) + '…', 'info');
        setChantierStatus(def.label + '…');
        await sleep(STEP_MIN_MS);
        var swapped = applyNationalSwap(def, def.build(ctx), ctx);
        var spec = swapped.spec;
        var resp = await runStep({
            num: def.num, label: swapped.label, action: spec.action,
            method: spec.method || 'GET', params: spec.params || {},
            body: spec.body !== undefined ? spec.body : null,
            extract: swapped.extract, summary: swapped.summary,
        }, ctx);
        groupNote(resp && resp.ok);
        if (!resp || !resp.ok) hadError = true;
        // Dès que la géométrie de la parcelle est disponible (step 3), on
        // affiche le bloc bleu sans attendre la fin du flow.
        updateMinimapBuilding();
    }
    endStepGroup();

    setChantierStatus('Immeubles mitoyens…');
    await sleep(STEP_MIN_MS);
    await collectMitoyens(ctx);

    updateMinimapBuilding();

    renderPocSteps();
    renderOutputStep();
    renderSurelevationStep();
    renderSurelevationReportStep();
    setChantierStatus(hadError ? 'Terminé avec erreurs' : 'Analyse terminée', true);
    setGlobalStatus(hadError ? 'Terminé avec erreurs' : 'Terminé', hadError ? 'error' : 'ok');
    logMsg('<strong>' + (hadError ? 'Terminé avec erreurs' : 'Terminé avec succès') + '</strong> — ' + Object.keys(allResults).length + ' étape(s)', hadError ? 'error' : 'ok');

    if (!hadError && nationalActive()) {
        logMsg('Sources nationales actives — résultat non mis en cache local (mix iudo/national)', 'info');
        postHistory(selected);
    } else if (!hadError) {
        if (saveCache(selected, allResults)) {
            logMsg('Flow mis en cache local (7 jours) — prochaine recherche de cette adresse instantanée', 'info');
        } else {
            logMsg('Cache local indisponible (quota navigateur) — le cache serveur reste actif', 'warn');
        }
        postHistory(selected);
    }
    forceLiveOnce = false;
    updateRunBtnLabel();
    $runBtn.disabled = false;
}

// ---- Immeubles mitoyens -----------------------------------------------------
// Les parcelles voisines viennent de parcel_detail (ctx.neighbors). On récupère
// l'unité foncière de chacune pour en extraire la hauteur du bâti, nécessaire à
// la moyenne demandée pour le calcul de surélévation.

function extractBuilding(unitData, parcelId) {
    var unit = Array.isArray(unitData) && unitData.length ? unitData[0] : (unitData || {});
    var b = unit.buildings || {};
    var addr = unit.address || {};
    return {
        parcelId: parcelId,
        adresse: addr.streetNbr ? ((addr.streetNbr + ' ' + (addr.streetName || '')).trim()) : (addr.streetName || null),
        hauteur_m: b.high != null ? b.high : null,
        niveaux: b.floor != null ? b.floor : null,
        logements: b.housing != null ? b.housing : null,
        usages: b.usages || null,
        anneeConstruction: b.date || null,
        surface_m2: b.surface != null ? Math.round(b.surface * 10) / 10 : null,
    };
}

async function fetchNeighborUnit(parcelId) {
    var proxyUrl = PROXY + '?action=parcel_units&parcelId=' + encodeURIComponent(parcelId);
    if (forceLiveOnce || ($nocache && $nocache.checked)) proxyUrl += '&nocache=1';
    try {
        var res = await timedFetch(proxyUrl, { headers: { 'X-Admin-Key': PROXY_KEY } }, 30000);
        var resp = JSON.parse(await res.text());
        return (resp && resp.ok) ? extractBuilding(resp.data, parcelId) : { parcelId: parcelId, error: resp && resp.error ? resp.error : 'échec' };
    } catch (e) {
        return { parcelId: parcelId, error: e.message };
    }
}

async function collectMitoyens(ctx) {
    var neighbors = (ctx && Array.isArray(ctx.neighbors)) ? ctx.neighbors : [];
    var box = renderStep('25b', 'Immeubles mitoyens', neighbors.length + ' parcelle(s) voisine(s)');
    setDot(box, neighbors.length ? 'running' : 'ok');

    var mitoyens = [];
    for (var i = 0; i < neighbors.length; i++) {
        logMsg('Mitoyen ' + (i + 1) + '/' + neighbors.length + ' — ' + esc(neighbors[i]) + '…', 'info');
        mitoyens.push(await fetchNeighborUnit(neighbors[i]));
    }

    allResults.step_25b_mitoyens = { ok: true, action: 'mitoyens', data: mitoyens };

    setDot(box, 'ok');
    var withH = mitoyens.filter(function (m) { return m.hauteur_m != null; });
    setMeta(box, mitoyens.length + ' mitoyen(s)');
    var lines = mitoyens.length
        ? mitoyens.map(function (m) {
            return kv(m.parcelId, m.hauteur_m != null ? m.hauteur_m + ' m · ' + (m.niveaux != null ? m.niveaux + ' niv.' : '?') : (m.error || 'sans bâti'));
          })
        : ['Aucune parcelle mitoyenne renvoyée par l\'API'];
    if (withH.length > 1) lines.push(kv('moyenne hauteurs', avgHeight(withH) + ' m'));
    renderSummary(box, 200, 0, lines);
    logMsg('Mitoyens collectés : ' + withH.length + '/' + mitoyens.length + ' avec hauteur', 'ok');
}

function avgHeight(list) {
    var hs = list.map(function (m) { return m.hauteur_m; }).filter(function (h) { return typeof h === 'number'; });
    if (!hs.length) return null;
    return Math.round((hs.reduce(function (a, b) { return a + b; }, 0) / hs.length) * 10) / 10;
}

async function runStep(cfg, ctx) {
    var num = cfg.num, label = cfg.label, action = cfg.action;
    var method = cfg.method || 'GET', params = cfg.params || {}, body = cfg.body;
    var extract = cfg.extract, summary = cfg.summary;

    var qs = new URLSearchParams(Object.assign({ action: action }, params)).toString();
    var proxyUrl = PROXY + '?' + qs;
    if (forceLiveOnce || ($nocache && $nocache.checked)) proxyUrl += '&nocache=1';
    var displayUrl = buildDisplayUrl(action, params, method);
    var box = renderStep(num, label, displayUrl);
    setDot(box, 'running');

    var t0 = performance.now();
    var resp = null;

    try {
        var fetchOpts = { method: method, headers: { 'X-Admin-Key': PROXY_KEY } };
        if (method === 'POST') {
            fetchOpts.headers['Content-Type'] = 'application/json';
            fetchOpts.body = body != null ? JSON.stringify(body) : '';
        }
        var res = await timedFetch(proxyUrl, fetchOpts, 30000);
        var text = await res.text();
        try { resp = JSON.parse(text); } catch (e) {
            resp = { ok: false, status: res.status, action: action, error: 'Réponse proxy non-JSON', raw: text };
        }
    } catch (err) {
        var errMsg = err.name === 'AbortError' ? 'Timeout (30s)' : err.message;
        resp = { ok: false, status: 0, action: action, error: 'Erreur réseau : ' + errMsg, raw: '' };
    }

    var dur = Math.round(performance.now() - t0);
    var durMs = resp && resp.duration_ms != null ? resp.duration_ms : dur;
    allResults['step_' + num + '_' + action] = resp;

    if (resp && resp.ok) {
        setDot(box, 'ok');
        setMeta(box, 'HTTP ' + resp.status + ' · ' + durMs + 'ms' + (resp.cached ? ' · cache' : ''));
        if (extract) { try { extract(resp.data, ctx); } catch (e) { /* ignore */ } }
        var lines = summary ? summary(resp, ctx).filter(Boolean) : [];
        if (resp.cached) {
            lines.push('<span class="df-k">cache serveur</span><span class="df-v">' + esc(resp.cached_at || 'oui') + '</span>');
        }
        renderSummary(box, resp.status, durMs, lines);
        logMsg('STEP ' + num + ' OK — HTTP ' + resp.status + ' — ' + durMs + 'ms' + (resp.cached ? ' — cache serveur' : ''), 'ok');
    } else {
        setDot(box, 'error');
        setMeta(box, resp && resp.status ? 'HTTP ' + resp.status : 'erreur');
        box.classList.add('open');
        renderError(box, resp, durMs);
        logMsg('STEP ' + num + ' ERREUR — ' + esc(resp ? resp.error || 'HTTP ' + resp.status : 'inconnu') + ' — ' + durMs + 'ms', 'error');
    }
    renderRawToggle(box, resp);
    return resp;
}

// ---- Annotations migration POC ----------------------------------------------
// todo   : pastille TODO bleue + note — étape à adapter (migration open data,
//          remplacement du compte iudo, maj IGN…).
// dashed : bordure pointillée — étape servie par iudo hors process métier V2
//          (à trancher garder/supprimer) ou purement technique iudo.

// open  : API publique de remplacement (pastille verte « API open » + quota,
//         vérifié le 16/07/2026). quota : conditions exactes.
var STEP_ANNOTATIONS = {
    '1':   { todo: 'Migrer le géocodage vers la Géoplateforme IGN (api-adresse.data.gouv.fr déprécié)',
             open: 'API Adresse BAN / Géoplateforme', quota: 'Gratuit, sans clé · 50 req/s par IP' },
    '2':   { dashed: true, todo: 'Compte iudo perso + mot de passe en dur — compte dédié, puis suppression après migration open data' },
    '3':   { todo: 'Remplacer par API Carto module Cadastre',
             open: 'API Carto Cadastre (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse (500 communes), pagination _start' },
    '4':   { todo: 'Remplacer par API Carto Cadastre (fournit aussi les parcelles voisines)',
             open: 'API Carto Cadastre (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse, pagination _start' },
    '5':   { todo: 'Remplacer par API Carto Cadastre (géométrie)',
             open: 'API Carto Cadastre (IGN)', quota: 'Gratuit, sans clé · géométrie en Lambert 93 (reprojeter vers WGS84)' },
    '6':   { todo: 'Remplacer par API Carto module GPU (zonage)',
             open: 'API Carto GPU (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse' },
    '7':   { todo: 'Remplacer par BD TOPO IGN + RNB — conversion hauteur faîtage → égout à spécifier ; propriétaires : pas d\'équivalent open data',
             open: 'BD TOPO — WFS Géoplateforme + RNB', quota: 'Gratuit, sans clé · 30 req/s par IP · max 2 couches/URL (depuis 15/06/2026)' },
    '8':   { todo: 'Règlement structuré : PAS d\'API open équivalente (les couches plub_* ne donnent que le graphique). À reconstruire via plub_* + PDF GPU + parsing.' },
    '9':   { dashed: true, todo: 'Endpoint iudo (renvoie null sur nos tests) — vérifier ou supprimer' },
    '10':  { todo: 'Remplacer par les couches plub_gpu_ps / pl / pp',
             open: 'Paris Data (Opendatasoft)', quota: 'Gratuit · quota anonyme non publié, clé API pour le relever' },
    '11':  { todo: 'Remplacer par API Carto GPU',
             open: 'API Carto GPU (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse' },
    '12':  { todo: 'Remplacer par le GPU en direct (téléchargement du PDF)',
             open: 'Géoportail de l\'Urbanisme', quota: 'Gratuit, sans clé · téléchargement direct du règlement' },
    '13':  { todo: 'Remplacer par le GPU (secteurs patrimoniaux)',
             open: 'API Carto GPU (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse' },
    '14':  { dashed: true, todo: 'Hors process métier V2 — si conservé : geo.api.gouv.fr',
             open: 'geo.api.gouv.fr', quota: 'Gratuit, sans clé' },
    '15':  { todo: 'Remplacer par DVF+ Cerema / fichiers DGFiP',
             open: 'API Données foncières (DVF+ Cerema)', quota: 'Gratuit, Licence Ouverte 2.0' },
    '16':  { todo: 'Remplacer par DVF+ Cerema / fichiers DGFiP',
             open: 'API Données foncières (DVF+ Cerema)', quota: 'Gratuit, Licence Ouverte 2.0' },
    '17':  { todo: 'Remplacer par DVF+ Cerema — le calcul exige ventes de la parcelle ET des mitoyens',
             open: 'API Données foncières (DVF+ Cerema)', quota: 'Gratuit, Licence Ouverte 2.0' },
    '18':  { dashed: true, todo: 'Hors process métier V2 — si conservé : Paris Data « encadrement des loyers »',
             open: 'Paris Data (Opendatasoft)', quota: 'Gratuit · maj annuelle au 1er juillet' },
    '19':  { dashed: true, todo: 'Hors process métier V2 — si conservé : Sitadel',
             open: 'Sitadel (open data)', quota: 'Gratuit · granularité parcelle à valider' },
    '20':  { dashed: true, todo: 'Hors process métier V2 — si conservé : Sitadel',
             open: 'Sitadel (open data)', quota: 'Gratuit · granularité parcelle à valider' },
    '21':  { dashed: true, todo: 'Hors process métier V2 — si conservé : Sitadel',
             open: 'Sitadel (open data)', quota: 'Gratuit · granularité parcelle à valider' },
    '22':  { todo: 'Remplacer par API Géorisques',
             open: 'API Géorisques v1', quota: 'Gratuit, sans jeton (v1) · v2 = jeton Cerbère (1 an)' },
    '23':  { todo: 'Remplacer par API Géorisques',
             open: 'API Géorisques v1', quota: 'Gratuit, sans jeton (v1) · v2 = jeton Cerbère (1 an)' },
    '24':  { dashed: true, todo: 'Tracking iudo — à supprimer après migration' },
    '25':  { dashed: true, todo: 'Compteur de quota iudo — à supprimer après migration' },
    '25b': { todo: 'Basculera sur API Carto + BD TOPO (comme les steps 3 à 7)',
             open: 'API Carto Cadastre + BD TOPO', quota: 'Gratuit, sans clé · voir steps 3 et 7' },
};

function annotateStep(box, num) {
    var ann = STEP_ANNOTATIONS[String(num)];
    if (!ann) return;
    if (ann.dashed) box.classList.add('df-dashed');
    var meta = box.querySelector('.df-step-meta');
    var body = box.querySelector('.df-step-body');
    if (ann.open && meta) {
        meta.insertAdjacentHTML('beforebegin',
            '<span class="df-open" title="' + esc(ann.open + ' — ' + ann.quota) + '">API open</span>');
    }
    if (ann.todo && meta) {
        meta.insertAdjacentHTML('beforebegin',
            '<span class="df-todo" title="' + esc(ann.todo) + '">TODO</span>');
    }
    if (body) {
        var note = '';
        if (ann.todo) note += '<div class="df-todo-note">&#128295; ' + esc(ann.todo) + '</div>';
        if (ann.open) note += '<div class="df-open-note">&#127760; <strong>' + esc(ann.open) + '</strong> — ' + esc(ann.quota) + '</div>';
        if (note) body.insertAdjacentHTML('afterbegin', note);
    }
}

// Étapes POC à construire (iudo ne les a pas) — purement front : rendu grisé,
// aucun appel backend. Sert de checklist visuelle dans le flow.
var POC_STEPS = [
    { id: 'P1',  label: 'Couches PLUb filets & hauteurs', desc: 'plub_filet / plub_hauteur / plub_hmc (Paris Data, gratuit) — table couleur → hauteur à re-sourcer (Jo + Théo)' },
    { id: 'P2',  label: 'Largeur de voie (prospect)', desc: 'Aucune API directe identifiée — filaire de voies Paris Data + calcul géométrique. Point dur.' },
    { id: 'P3',  label: 'Conversion faîtage IGN → égout', desc: 'Règle métier à spécifier — IGN mesure le faîtage, le gabarit PLU raisonne à l\'égout' },
    { id: 'P4',  label: 'Calcul hauteur autorisée', desc: 'Gabarit-enveloppe : filet coloré, ou largeur de voie (< 12 m refus, sinon H = P + 25 %P ≤ 28 m) + couronnement' },
    { id: 'P5',  label: 'Conditionnalité PLUb + dent creuse', desc: 'Filtres : création logement, débitumisation, biosourcés, > RE2020, souvent 1 niveau ; comparaison aux 2 voisins concomitants' },
    { id: 'P6',  label: 'Filtre patrimonial amont', desc: 'Fiche synthétique immeuble PLU, flag bâtiment d\'angle, sortie « vérification à faire en mairie »' },
    { id: 'P7',  label: 'Moteur économique', desc: 'Hauteur disponible (seuil 2,80 m), niveaux = floor(h/3), surface × abattement −20 %, prix DVF (immeuble + mitoyens) × 1,20, CA TTC' },
    { id: 'P8',  label: 'Bilan promoteur', desc: 'CA TTC − 20 % TVA − 50 % coûts − 15 % marge APDS = 15 % acquisition copro (10 % si < 200 m² et foncier < 8 k€/m²)' },
    { id: 'P9',  label: 'Score de fiabilité A / B / C / D', desc: 'A = PLU lu + hauteur fiable + parcelle claire … D = zone protégée / donnée manquante' },
    { id: 'P10', label: 'Statut GO / À VÉRIFIER / NO-GO', desc: 'Filtre patrimonial intégré, restitution en fourchettes non juridique — wording à arbitrer' },
];

function renderPocSteps() {
    var box = document.createElement('div');
    box.className = 'df-step df-group df-poc open';
    box.innerHTML =
        '<div class="df-step-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<div><span class="df-step-title">STEPS POC — À CONSTRUIRE (' + POC_STEPS.length + ')</span>' +
            '<span class="df-step-url">Étapes du process métier absentes de iudo — front uniquement, aucun appel</span></div>' +
            '<span class="df-step-meta">0 / ' + POC_STEPS.length + '</span>' +
            '<span class="df-dot pending"></span>' +
        '</div>' +
        '<div class="df-step-body"></div>';
    box.querySelector('.df-step-head').addEventListener('click', function () {
        box.classList.toggle('open');
    });
    $steps.appendChild(box);

    var container = box.querySelector('.df-step-body');
    POC_STEPS.forEach(function (p) {
        var el = document.createElement('div');
        el.className = 'df-step df-poc-step';
        el.innerHTML =
            '<div class="df-step-head">' +
                '<span class="df-caret">&#9654;</span>' +
                '<div><span class="df-step-title">STEP ' + esc(p.id) + ' — ' + esc(p.label) + '</span></div>' +
                '<span class="df-step-meta">à construire</span>' +
                '<span class="df-dot pending"></span>' +
            '</div>' +
            '<div class="df-step-body"><div class="df-step-summary"><span class="df-line">' + esc(p.desc) + '</span></div></div>';
        el.querySelector('.df-step-head').addEventListener('click', function () {
            el.classList.toggle('open');
        });
        container.appendChild(el);
    });
}

// ---- Minimap 3D + loader chantier -------------------------------------------
// Carte 3D (MapLibre + ortho IGN) qui zoome dynamiquement sur l'immeuble, avec
// une grue animée façon Kayak et une box de statut affichant chaque étape en
// cours (remplace la précédente) pour valoriser le temps de traitement.

// Le rendu carte (style, vol caméra, quartier 3D, volumes, orbite) est délégué
// au composant partagé /av-minimap.js (window.AvMinimap) — le MÊME que la
// landing. Ici ne restent que le métier : identification WFS de l'immeuble,
// fallback parcelle, fiche d'audit et barre chantier.
var avMap = null; // instance AvMinimap ({ map, whenReady, flyIn, addBlue, ... })

var CRANE_SVG =
    '<svg class="df-crane-svg" viewBox="0 0 64 64" width="46" height="46" aria-hidden="true">' +
        '<g stroke="#fbbf24" stroke-width="2.4" fill="none" stroke-linecap="round">' +
            '<line x1="14" y1="58" x2="14" y2="10"/>' +            // mât
            '<line x1="8" y1="58" x2="20" y2="58"/>' +            // base
            '<line x1="14" y1="12" x2="52" y2="12"/>' +           // flèche
            '<line x1="14" y1="12" x2="4" y2="20"/>' +            // contre-flèche
            '<line x1="14" y1="20" x2="24" y2="12"/>' +           // hauban
        '</g>' +
        '<g class="df-crane-hook">' +
            '<line x1="44" y1="12" x2="44" y2="30" stroke="#f59e0b" stroke-width="1.6"/>' +
            '<rect x="40" y="30" width="8" height="6" rx="1" fill="#22c55e"/>' +
        '</g>' +
        '<g transform="translate(11,44)">' +                       // ouvrier
            '<circle cx="3" cy="0" r="2.4" fill="#fbbf24"/>' +
            '<line x1="3" y1="2.4" x2="3" y2="9" stroke="#fbbf24" stroke-width="2"/>' +
            '<g class="df-crane-arm"><line x1="3" y1="4" x2="8" y2="4" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/></g>' +
        '</g>' +
    '</svg>';

function parcelInfoFromResults() {
    function poly(g) { return g && g.type === 'Polygon' && Array.isArray(g.coordinates) ? g : null; }
    var geometry = null, height = null;
    var u = allResults.step_7_parcel_units && allResults.step_7_parcel_units.ok
        ? (Array.isArray(allResults.step_7_parcel_units.data) ? allResults.step_7_parcel_units.data[0] : null) : null;
    if (u) {
        if (u.parcels && u.parcels[0]) geometry = poly(u.parcels[0].geometry);
        if (u.buildings && u.buildings.high != null) height = u.buildings.high;
    }
    if (!geometry) {
        var p3 = allResults.step_3_parcels && allResults.step_3_parcels.data;
        if (p3) geometry = poly(p3.geometry) || (Array.isArray(p3) && p3[0] ? poly(p3[0].geometry) : null);
    }
    if (!geometry) {
        var p4 = allResults.step_4_parcel_detail && allResults.step_4_parcel_detail.data;
        if (p4 && p4.parcels && p4.parcels[0]) geometry = poly(p4.parcels[0].geometry);
    }
    return geometry ? { geometry: geometry, height: (height != null ? height : 12) } : null;
}

// Exécute fn dès que le style de la carte est prêt (délégué au composant).
function whenMapReady(fn) {
    if (avMap) avMap.whenReady(fn);
}

function setMapStatus(text, isError) {
    var el = document.getElementById('df-map-status');
    if (!el) return;
    el.textContent = text || '';
    el.classList.toggle('df-minimap-foot-err', !!isError);
}

var minimapStartAt = 0;
var animStartAt = 0; // début du vol caméra — ancre de la chorégraphie

// Chorégraphie métier, relative au début du vol caméra (le quartier 3D et
// l'orbite fin-de-vol sont gérés par le composant).
var BLEU_AT_MS = 2000; // notre immeuble en bleu : +2 s

// Exécute fn à `offsetMs` après le début de l'animation (tout de suite si déjà
// passé), une fois la carte prête.
function scheduleAtAnim(offsetMs, fn) {
    var delay = Math.max(0, animStartAt + offsetMs - performance.now());
    setTimeout(function () { whenMapReady(fn); }, delay);
}

function renderMinimap() {
    minimapStartAt = performance.now();
    minimapBuilt = false;
    blueFromWfs = false;
    wfsState = 'pending';
    parcelFallbackInfo = null;
    blueGeometry = null;
    blueHeight = null;
    if (avMap) { avMap.destroy(); avMap = null; }
    var box = document.createElement('div');
    box.className = 'df-minimap';
    box.innerHTML =
        '<div class="df-minimap-head">' +
            '<span class="df-minimap-title">&#127961; ' + esc(selected.label || 'Immeuble') + '</span>' +
            '<span class="df-minimap-sub">lat ' + selected.lat + ' · lon ' + selected.lon + '</span>' +
        '</div>' +
        '<div class="df-minimap-canvas" id="df-map"></div>' +
        '<div class="df-minimap-foot" id="df-map-status"></div>' +
        '<div class="df-minimap-msg" id="df-map-msg">Chargement de la vue 3D…</div>' +
        // Fiche d'audit gratuite intégrée à la carte (droite en desktop, bas en mobile)
        '<div class="df-audit-card" id="df-audit-card">' +
            '<span class="df-audit-badge">Audit gratuit</span>' +
            '<div class="df-audit-title">' + esc(selected.label || 'Votre immeuble') + '</div>' +
            '<div class="df-audit-rows" id="df-audit-rows">' +
                '<div class="df-audit-row df-audit-wait">Analyse en cours…</div>' +
            '</div>' +
            '<button class="df-audit-cta" type="button">Recevoir l\'audit complet</button>' +
        '</div>';
    $steps.appendChild(box);

    renderChantier();

    if (typeof maplibregl === 'undefined' || !window.AvMinimap) {
        var msg = document.getElementById('df-map-msg');
        if (msg) msg.textContent = 'Vue 3D indisponible (librairie carto non chargée).';
        return;
    }

    // Composant partagé admin/landing : style, vol caméra, quartier 3D et
    // orbite 360° (démarrée à la FIN du vol, indépendante des recherches).
    animStartAt = performance.now() + 250;
    avMap = window.AvMinimap.create({
        container: 'df-map',
        lon: selected.lon, lat: selected.lat,
        startZoom: 15.5, navControl: true,
        onReady: function () {
            var m = document.getElementById('df-map-msg');
            if (m) m.style.display = 'none';
        },
    });
    if (!avMap) {
        var m = document.getElementById('df-map-msg');
        if (m) m.textContent = 'Vue 3D indisponible (initialisation carte impossible).';
        return;
    }
    avMap.flyIn(250);
    // Le fetch WFS part immédiatement ; seul l'affichage du bleu est calé (+2 s).
    fetchOurBuilding();
}

// Remplit la fiche d'audit gratuite (sur la carte) à partir du rapport de
// surélévation, une fois le flow terminé.
function updateAuditCard() {
    var rowsEl = document.getElementById('df-audit-rows');
    if (!rowsEl) return;
    var rep;
    try { rep = buildSurelevationReport(allResults); } catch (e) { return; }
    var rows = [];
    var z = rep.zonePlu || {};
    if (z.name || z.libelong) rows.push(['Zone PLU', z.name || z.libelong]);
    if (rep.parcelle && rep.parcelle.surface_m2 != null) rows.push(['Parcelle', rep.parcelle.surface_m2 + ' m²']);
    var b = rep.batimentExistant || {};
    if (b.hauteur_m != null) rows.push(['Hauteur actuelle', b.hauteur_m + ' m']);
    if (b.niveaux != null) rows.push(['Niveaux', b.niveaux]);
    var nbRegles = (rep.reglesSurelevation && rep.reglesSurelevation.length) || 0;
    rows.push(['Règles de surélévation', nbRegles + ' repérée(s)']);
    rowsEl.innerHTML = rows.length
        ? rows.map(function (r) { return '<div class="df-audit-row"><span>' + esc(r[0]) + '</span><strong>' + esc(String(r[1])) + '</strong></div>'; }).join('')
        : '<div class="df-audit-row df-audit-wait">Données indisponibles</div>';
}

// NOTRE immeuble en GeoJSON via le WFS BD TOPO (BDTOPO_V3:batiment, attribut
// « hauteur » documenté). Deux variantes de requête essayées dans l'ordre —
// les serveurs WFS diffèrent sur l'ordre des axes / le CRS accepté.
function wfsBatimentsUrls(lon, lat) {
    var dLon = 0.0026, dLat = 0.0017; // ≈ ±190 m
    var base = 'https://data.geopf.fr/wfs/ows?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature'
        + '&TYPENAME=BDTOPO_V3:batiment&TYPENAMES=BDTOPO_V3:batiment'
        + '&OUTPUTFORMAT=application/json&COUNT=400';
    return [
        base + '&SRSNAME=EPSG:4326&BBOX=' + [lat - dLat, lon - dLon, lat + dLat, lon + dLon, 'EPSG:4326'].join(','),
        base + '&SRSNAME=CRS:84&BBOX=' + [lon - dLon, lat - dLat, lon + dLon, lat + dLat, 'CRS:84'].join(','),
    ];
}

function fetchWfsBatiments(lon, lat) {
    var urls = wfsBatimentsUrls(lon, lat);
    function attempt(i) {
        return fetch(urls[i])
            .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
            .then(function (fc) {
                if (fc && Array.isArray(fc.features) && fc.features.length) return fc;
                throw new Error('0 bâtiment renvoyé');
            })
            .catch(function (e) {
                if (i + 1 < urls.length) return attempt(i + 1);
                throw e;
            });
    }
    return attempt(0);
}

// Certains serveurs WFS renvoient les coordonnées en (lat, lon) selon le CRS
// demandé : on détecte et on remet en (lon, lat) attendu par GeoJSON/MapLibre.
function normalizeAxes(fc, lon0, lat0) {
    var first = null;
    (function find(c) {
        if (first) return;
        if (Array.isArray(c) && typeof c[0] === 'number') { first = c; return; }
        if (Array.isArray(c)) c.forEach(find);
    })((fc.features || []).map(function (f) { return f.geometry && f.geometry.coordinates; }));
    if (!first) return fc;
    var normal = Math.abs(first[0] - lon0) + Math.abs(first[1] - lat0);
    var swapped = Math.abs(first[0] - lat0) + Math.abs(first[1] - lon0);
    if (swapped < normal) {
        (function swap(c) {
            if (Array.isArray(c) && typeof c[0] === 'number') { var t = c[0]; c[0] = c[1]; c[1] = t; return; }
            if (Array.isArray(c)) c.forEach(swap);
        })(fc.features.map(function (f) { return f.geometry && f.geometry.coordinates; }));
    }
    return fc;
}

function pointInRing(pt, ring) {
    var inside = false;
    for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
        var xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1];
        if (((yi > pt[1]) !== (yj > pt[1])) && (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) inside = !inside;
    }
    return inside;
}

function pointInGeometry(pt, g) {
    if (!g) return false;
    if (g.type === 'Polygon') return pointInRing(pt, g.coordinates[0] || []);
    if (g.type === 'MultiPolygon') {
        return g.coordinates.some(function (poly) { return pointInRing(pt, poly[0] || []); });
    }
    return false;
}

var minimapBuilt = false;
var blueFromWfs = false;
var blueGeometry = null; // empreinte du bâtiment (réutilisée pour la surélévation)
var blueHeight = null;   // hauteur du bâtiment existant (m)
// État de l'identification WFS : le fallback parcelle ne doit s'appliquer que
// si le WFS a échoué — pas gagner la course pendant qu'il répond.
var wfsState = 'pending'; // 'found' | 'none' | 'error'
var parcelFallbackInfo = null;

// Notre immeuble en bleu (rendu délégué au composant, garde-fous métier ici :
// un seul bloc, priorité WFS sur le fallback parcelle).
function addBlueBlock(geometry, heightValue, fromWfs) {
    if (!avMap || minimapBuilt) return;
    minimapBuilt = true;
    blueFromWfs = !!fromWfs;
    blueGeometry = geometry;
    blueHeight = heightValue;
    avMap.addBlue(geometry, heightValue);
}

// Si le bloc vient du fallback parcelle (hauteur par défaut), on affine sa
// hauteur quand le bâti réel arrive (step 7), sans le reconstruire.
function updateBlueHeight(geometry, h) {
    if (!avMap || !minimapBuilt || blueFromWfs || !h) return;
    blueGeometry = geometry;
    blueHeight = h;
    avMap.updateBlueHeight(geometry, h);
}

function applyParcelFallback() {
    if (!parcelFallbackInfo) return;
    var info = parcelFallbackInfo;
    scheduleAtAnim(BLEU_AT_MS, function () { addBlueBlock(info.geometry, info.height, false); });
}

// Volume de surélévation : hauteur ajoutée + faisabilité (couleur).
function surelevationSpec(rep) {
    var existant = rep.batimentExistant && rep.batimentExistant.hauteur_m;
    var regs = (rep.hauteursReglementaires && rep.hauteursReglementaires.valeursRepereesM) || [];
    var maxReg = 0;
    regs.forEach(function (h) { if (typeof h === 'number' && h > maxReg) maxReg = h; });
    var delta = null;
    if (maxReg && existant != null && maxReg > existant) delta = maxReg - existant;
    else if (rep.calcul && typeof rep.calcul.ecartMoyenneMitoyens_m === 'number' && rep.calcul.ecartMoyenneMitoyens_m > 0) {
        delta = rep.calcul.ecartMoyenneMitoyens_m;
    }
    var c = rep.contraintes || {};
    var bloque = c.psmv === true
        || (c.monumentsHistoriquesProximite || 0) > 0
        || (c.plansPreventionRisques || 0) > 0
        || (rep.parcelle && rep.parcelle.protegee === true);
    // Plafond réglementaire connu mais déjà atteint/dépassé → pas de marge.
    var sansMarge = maxReg > 0 && existant != null && maxReg <= existant;
    var faisable = !bloque && !sansMarge && (delta == null || delta > 0);
    // Hauteur du volume : le potentiel si connu, sinon ~1 niveau ; bornée à l'affichage.
    var addH = (delta && delta > 0) ? delta : 3;
    addH = Math.max(2.5, Math.min(addH, 12));
    return { addH: addH, faisable: faisable, delta: delta };
}

// Petit volume de surélévation posé sur le toit du volume bleu : vert si
// faisable, gris si contrainte patrimoniale/réglementaire bloquante.
function addSurelevationBlock(tries) {
    tries = tries || 0;
    if (!avMap) return;
    if ((!minimapBuilt || !blueGeometry) && tries < 12) {
        setTimeout(function () { addSurelevationBlock(tries + 1); }, 500);
        return;
    }
    if (!blueGeometry) return;
    var rep;
    try { rep = buildSurelevationReport(allResults); } catch (e) { return; }
    var spec = surelevationSpec(rep);
    avMap.setSurelev(spec.addH, spec.faisable);
    updateAuditFeasibility(spec);
}

// Ligne « faisabilité » dans la fiche d'audit (pastille verte / grise).
function updateAuditFeasibility(spec) {
    var card = document.getElementById('df-audit-card');
    if (!card) return;
    var el = card.querySelector('.df-audit-feasible');
    if (!el) {
        el = document.createElement('div');
        el.className = 'df-audit-feasible';
        var rows = document.getElementById('df-audit-rows');
        if (rows && rows.parentNode) rows.parentNode.insertBefore(el, rows.nextSibling);
        else card.appendChild(el);
    }
    el.innerHTML = spec.faisable
        ? '<span class="df-audit-dot df-audit-dot-ok"></span> Surélévation a priori faisable'
        : '<span class="df-audit-dot df-audit-dot-no"></span> Surélévation contrainte';
}

// Identification de NOTRE immeuble : le polygone WFS qui contient le point de
// l'adresse, affiché en bleu à la seconde 1 de l'animation (pas avant, pour
// laisser le zoom s'installer ; pas après, dès que la donnée est là).
function fetchOurBuilding() {
    setMapStatus('Bâtiments 3D : identification de l\'immeuble…');
    fetchWfsBatiments(selected.lon, selected.lat)
        .then(function (fc) {
            if (!avMap) return;
            normalizeAxes(fc, selected.lon, selected.lat);
            var pt = [selected.lon, selected.lat];
            var main = null;
            fc.features.some(function (f) {
                if (pointInGeometry(pt, f.geometry)) { main = f; return true; }
                return false;
            });
            if (!main) {
                wfsState = 'none';
                setMapStatus('Immeuble non trouvé sous le point BD TOPO — parcelle en fallback (step 3)');
                logMsg('Minimap : aucun bâtiment BD TOPO sous le point (' + fc.features.length + ' aux alentours) — fallback parcelle', 'warn');
                applyParcelFallback();
                return;
            }
            wfsState = 'found';
            var h = main.properties && main.properties.hauteur != null ? Number(main.properties.hauteur) : NaN;
            var height = isNaN(h) || h <= 0 ? 15 : h;
            setMapStatus('Immeuble identifié — hauteur BD TOPO : ' + (isNaN(h) ? 'inconnue' : h + ' m'));
            logMsg('Minimap : immeuble identifié via WFS BD TOPO (' + height + ' m)', 'ok');
            // Apparition calée à +2 s du début de l'animation.
            scheduleAtAnim(BLEU_AT_MS, function () { addBlueBlock(main.geometry, height, true); });
        })
        .catch(function (err) {
            wfsState = 'error';
            setMapStatus('WFS BD TOPO indisponible (' + ((err && err.message) || 'erreur') + ') — parcelle en fallback', true);
            logMsg('Minimap : WFS BD TOPO indisponible : ' + esc((err && err.message) || 'erreur'), 'warn');
            applyParcelFallback();
        });
}

// Fallback + contour de parcelle quand la géométrie arrive du flow (step 3+).
function updateMinimapBuilding() {
    if (!avMap) return;
    var info = parcelInfoFromResults();
    if (!info) return;

    whenMapReady(function () {
        // Contour cyan de la parcelle (complète le bloc bleu du bâtiment).
        try {
            var m = avMap.map;
            if (!m.getSource('parcelle')) {
                m.addSource('parcelle', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: info.geometry } });
                m.addLayer({
                    id: 'parcelle-line', type: 'line', source: 'parcelle',
                    paint: { 'line-color': '#22d3ee', 'line-width': 2, 'line-dasharray': [2, 1.5], 'line-opacity': 0.9 },
                });
            }
        } catch (e) { /* ignore */ }
        // Priorité au WFS : la parcelle ne devient bloc bleu que si le WFS a
        // échoué ; s'il est encore en cours, on mémorise pour son .catch.
        parcelFallbackInfo = info;
        if (wfsState === 'error' || wfsState === 'none') {
            scheduleAtAnim(BLEU_AT_MS, function () { addBlueBlock(info.geometry, info.height, false); });
        }
        // Bloc parcelle déjà posé avec la hauteur par défaut : affine quand le
        // bâti réel (step 7) est connu.
        updateBlueHeight(info.geometry, info.height);
    });
}

// Durée minimale d'affichage de chaque étape dans la box de statut (≥ 3 s),
// pour bien voir chaque étape défiler. Surchargée par window.__DF_STEP_MS
// (tests) pour ne pas ralentir l'automatisation.
var STEP_MIN_MS = (typeof window !== 'undefined' && window.__DF_STEP_MS != null) ? window.__DF_STEP_MS : 3000;
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }

// Barre chantier : mascotte AirValue + grue animée + box de statut (une étape
// à la fois).
function renderChantier() {
    var el = document.createElement('div');
    el.className = 'df-chantier';
    el.innerHTML =
        '<img class="df-mascotte" src="/mascotte.png" alt="" aria-hidden="true" />' +
        '<span class="df-crane">' + CRANE_SVG + '</span>' +
        '<span class="df-chantier-status" id="df-chantier-status">Initialisation…</span>';
    $steps.appendChild(el);
}

function setChantierStatus(text, done) {
    var el = document.getElementById('df-chantier-status');
    if (!el) return;
    var label = String(text).replace(/[.…\s]+$/, '');
    if (done) {
        el.textContent = text;
    } else {
        // Libellé + points animés « . .. ... » qui défilent rapidement.
        el.innerHTML = esc(label) + '<span class="df-dots" aria-hidden="true"></span>';
    }
    var bar = el.parentNode;
    if (bar) {
        bar.classList.toggle('df-chantier-done', !!done);
        var crane = bar.querySelector('.df-crane');
        if (crane) crane.classList.toggle('df-crane-done', !!done);
    }
}

function renderLegend() {
    var el = document.createElement('div');
    el.className = 'df-legend';
    el.innerHTML =
        '<span><span class="df-todo">TODO</span> à adapter (migration open data)</span>' +
        '<span><span class="df-open">API open</span> API publique gratuite dispo (survol = quota)</span>' +
        '<span><span class="df-legend-dashed"></span> iudo hors process métier — à trancher</span>' +
        '<span><span class="df-legend-poc"></span> à construire (iudo ne l\'a pas)</span>';
    $steps.appendChild(el);
}

// ---- Groupe repliable des steps 7 → 25 --------------------------------------
// Les steps de collecte détaillée sont rangés dans une box unique, repliée par
// défaut ; elle s'ouvre seule si un step en erreur s'y trouve.

var GROUP_FROM = 7, GROUP_TO = 25;
var currentGroup = null;

function startStepGroup() {
    var box = document.createElement('div');
    box.className = 'df-step df-group';
    box.innerHTML =
        '<div class="df-step-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<div><span class="df-step-title">STEPS ' + GROUP_FROM + ' → ' + GROUP_TO + ' — Collecte de données détaillées</span>' +
            '<span class="df-step-url">' + (GROUP_TO - GROUP_FROM + 1) + ' appels API — déplier pour le détail</span></div>' +
            '<span class="df-step-meta"></span>' +
            '<span class="df-dot running"></span>' +
        '</div>' +
        '<div class="df-step-body"></div>';
    box.querySelector('.df-step-head').addEventListener('click', function () {
        box.classList.toggle('open');
    });
    $steps.appendChild(box);
    currentGroup = { box: box, ok: 0, err: 0 };
    $stepContainer = box.querySelector('.df-step-body');
}

function groupNote(success) {
    if (!currentGroup) return;
    if (success) currentGroup.ok++; else currentGroup.err++;
    setMeta(currentGroup.box, currentGroup.ok + ' OK' + (currentGroup.err ? ' · ' + currentGroup.err + ' erreur(s)' : ''));
    if (currentGroup.err) {
        setDot(currentGroup.box, 'error');
        currentGroup.box.classList.add('open');
    }
}

function endStepGroup() {
    if (currentGroup) setDot(currentGroup.box, currentGroup.err ? 'error' : 'ok');
    currentGroup = null;
    $stepContainer = $steps;
}

function renderStep(num, label, url) {
    var box = document.createElement('div');
    box.className = 'df-step';
    box.innerHTML =
        '<div class="df-step-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<div>' +
                '<span class="df-step-title">STEP ' + num + ' — ' + esc(label) + '</span>' +
                '<span class="df-step-url">' + esc(url) + '</span>' +
            '</div>' +
            '<span class="df-step-meta"></span>' +
            '<span class="df-dot pending"></span>' +
        '</div>' +
        '<div class="df-step-body"></div>';
    box.querySelector('.df-step-head').addEventListener('click', function () {
        box.classList.toggle('open');
    });
    annotateStep(box, num);
    ($stepContainer || $steps).appendChild(box);
    return box;
}

function setDot(box, state) { box.querySelector('.df-dot').className = 'df-dot ' + state; }

function setMeta(box, text) {
    var meta = box.querySelector('.df-step-meta');
    if (meta) meta.textContent = text;
}

function renderSummary(box, status, durMs, lines) {
    var body = box.querySelector('.df-step-body');
    var rest = lines.map(function (l) { return '<span class="df-line">' + l + '</span>'; }).join('');
    if (rest) body.insertAdjacentHTML('beforeend', '<div class="df-step-summary">' + rest + '</div>');
}

function renderError(box, resp, durMs) {
    var body = box.querySelector('.df-step-body');
    var status = resp ? resp.status : 0;
    var errMsg = resp && resp.error ? resp.error : 'Erreur inconnue';
    var raw = resp && (resp.raw || resp.data)
        ? (typeof resp.raw === 'string' && resp.raw ? resp.raw : JSON.stringify(resp.data || resp.raw, null, 2))
        : '';
    body.insertAdjacentHTML('beforeend',
        '<div class="df-step-summary"><span class="df-line"><span class="df-v">' + status + '</span> — ' + durMs + 'ms</span></div>' +
        '<div class="df-step-error"><strong>HTTP ' + status + '</strong> — ' + esc(errMsg) +
        (raw ? '<pre>' + esc(raw) + '</pre>' : '') + '</div>');
}

function renderRawToggle(box, resp) {
    var body = box.querySelector('.df-step-body');
    var id = 'dfraw_' + Math.random().toString(36).slice(2);
    body.insertAdjacentHTML('beforeend',
        '<div class="df-json-actions">' +
            '<button class="df-toggle-raw" data-mode="compact">JSON compact</button>' +
            '<button class="df-toggle-raw" data-mode="raw">JSON brut</button>' +
        '</div>' +
        '<pre class="df-raw-json" id="' + id + '"></pre>');
    var pre = body.querySelector('.df-raw-json');
    var currentMode = null;
    body.querySelectorAll('.df-json-actions .df-toggle-raw').forEach(function (btn) {
        btn.addEventListener('click', function () {
            var mode = btn.getAttribute('data-mode');
            if (currentMode === mode) {
                pre.classList.remove('open');
                currentMode = null;
                return;
            }
            pre.textContent = mode === 'compact'
                ? JSON.stringify(compactStepResp(resp), null, 2)
                : JSON.stringify(resp, null, 2);
            pre.classList.add('open');
            currentMode = mode;
        });
    });
}

function renderSkippedStep(def, reason) {
    var spec = safeBuild(def);
    var url = spec ? buildDisplayUrl(spec.action, spec.params || {}, spec.method || 'GET') : '';
    var box = renderStep(def.num, def.label, url);
    setDot(box, 'error');
    setMeta(box, 'ignoré');
    box.classList.add('open');
    box.querySelector('.df-step-body').insertAdjacentHTML('beforeend',
        '<div class="df-step-error"><strong>Ignoré</strong> — ' + esc(reason) + '</div>');
}

function safeBuild(def) { try { return def.build({}); } catch (e) { return null; } }

function renderGeocodeStep() {
    var url = GEOCODE_URL + '?q=' + encodeURIComponent(selected.label);
    var box = renderStep(1, 'Géocodage IGN (BAN)', url);
    setDot(box, 'ok');
    setMeta(box, 'HTTP 200');
    allResults['step_1_geocode'] = { ok: true, status: 200, action: 'geocode', data: selected._feature };
    renderSummary(box, 200, 0, [
        'Adresse : <span class="df-v">' + esc(selected.label) + '</span>',
        kv('lat', selected.lat) + ' ' + kv('lon', selected.lon) + ' ' + kv('insee', selected.insee),
        kv('commune', selected.commune || '—'),
    ]);
    renderRawToggle(box, allResults['step_1_geocode']);
    logMsg('STEP 1 — Géocodage OK : ' + esc(selected.label), 'ok');
}

// ---- Compaction du JSON final ---------------------------------------------
// Le JSON agrégé brut dépasse facilement 400 Ko (règlement PLU complet, listes
// d'URLs PDF, milliers d'ids PSC). La version compacte garde toutes les infos
// liées à l'adresse (parcelle, zones, servitudes, propriétaires, DVF, risques…)
// et résume le reste. Le JSON brut reste disponible via « Télécharger complet ».

function truncList(arr, max) {
    if (!Array.isArray(arr) || arr.length <= max) return arr;
    return { total: arr.length, premiers: arr.slice(0, max) };
}

function excerpt(text, max) {
    if (typeof text !== 'string') return null;
    var t = text.replace(/\s+/g, ' ').trim();
    if (!t) return null;
    return t.length > max ? t.slice(0, max) + '…' : t;
}

function compactGeocode(feature) {
    if (!feature || typeof feature !== 'object') return feature;
    var coords = (feature.geometry && feature.geometry.coordinates) || [];
    return Object.assign({ lat: coords[1], lon: coords[0] }, feature.properties || {});
}

function compactLogin(data) {
    if (!data || typeof data !== 'object') return data;
    return {
        userId: data._id || data.id || null,
        status: data.status || null,
        subscription: (data.subscription && data.subscription.name) || null,
    };
}

function compactParcelUnits(data) {
    if (!Array.isArray(data)) return data;
    return data.map(function (unit) {
        if (!unit || typeof unit !== 'object') return unit;
        var copy = Object.assign({}, unit);
        if (copy.gpu && typeof copy.gpu === 'object' && !Array.isArray(copy.gpu)) {
            var gpu = {};
            Object.keys(copy.gpu).forEach(function (k) { gpu[k] = truncList(copy.gpu[k], 3); });
            copy.gpu = gpu;
        }
        return copy;
    });
}

function compactAirtablePlu(payload) {
    if (!payload || typeof payload !== 'object') return payload;
    var inner = payload.data && typeof payload.data === 'object' ? payload.data : {};
    var blocks = inner.blocks && typeof inner.blocks === 'object' ? inner.blocks : {};
    var out = { meta: inner.meta || null };

    var keyData = blocks.KEY_DATA && blocks.KEY_DATA.value;
    if (keyData && typeof keyData === 'object') {
        var dc = {};
        Object.keys(keyData).forEach(function (k) {
            var entry = keyData[k] || {};
            dc[entry.key || k] = entry.value != null ? entry.value : null;
        });
        out.donneesCles = dc;
    }

    var resume = {};
    Object.keys(blocks).forEach(function (bk) {
        if (bk === 'KEY_DATA') return;
        var block = blocks[bk] || {};
        var sections = block.value && typeof block.value === 'object' ? block.value : {};
        var compactSections = {};
        Object.keys(sections).forEach(function (sk) {
            var section = sections[sk] || {};
            var rubs = section.value && typeof section.value === 'object' ? section.value : {};
            var compactRubs = {};
            Object.keys(rubs).forEach(function (rk) {
                var rub = rubs[rk] || {};
                var c = {};
                if (typeof rub.tag === 'string' && rub.tag) c.tag = rub.tag;
                else if (rub.tag && typeof rub.tag === 'object') c.tags = rub.tag;
                if (typeof rub.resum === 'string' && rub.resum) c.resum = rub.resum;
                if (!c.tag && !c.tags && !c.resum) {
                    var ex = excerpt(rub.textSource, 300);
                    if (ex) c.extrait = ex;
                }
                if (Object.keys(c).length) compactRubs[rk] = c;
            });
            compactSections[section.key || sk] = compactRubs;
        });
        resume[block.key || bk] = compactSections;
    });
    out.reglementResume = resume;
    return out;
}

function compactTownPsc(data) {
    if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
    // psc_pct / psc_lin / psc_surf ne contiennent que des ids internes d'objets
    // graphiques (protections ponctuelles / linéaires / surfaciques) : on ne
    // garde que leur nombre.
    var out = {};
    Object.keys(data).forEach(function (k) {
        out[k] = Array.isArray(data[k]) ? data[k].length : data[k];
    });
    return out;
}

function compactGpuPluInfo(data) {
    if (!data || typeof data !== 'object') return data;
    var copy = Object.assign({}, data);
    copy.regulationGraphicPdfUrls = truncList(copy.regulationGraphicPdfUrls, 3);
    return copy;
}

function compactAduInsee(data) {
    if (!data || typeof data !== 'object' || !Array.isArray(data.features)) return data;
    var copy = Object.assign({}, data);
    copy.features = truncList(copy.features, 3);
    return copy;
}

var COMPACTORS = {
    geocode:      compactGeocode,
    login:        compactLogin,
    parcel_units: compactParcelUnits,
    airtable_plu: compactAirtablePlu,
    town_psc:     compactTownPsc,
    gpu_plu_info: compactGpuPluInfo,
    adu_insee:    compactAduInsee,
};

function compactStepResp(resp, action) {
    if (!resp || typeof resp !== 'object') return resp;
    if (resp.skipped) return { skipped: true, reason: resp.reason };
    if (!resp.ok) return { ok: false, status: resp.status, error: resp.error || null };
    var data = resp.data != null ? resp.data : (resp.raw ? excerpt(resp.raw, 500) : null);
    var fn = COMPACTORS[action || resp.action];
    try { return fn ? fn(data) : data; } catch (e) { return data; }
}

function buildCompactOutput(results) {
    var out = {};
    Object.keys(results).forEach(function (key) {
        out[key] = compactStepResp(results[key], key.replace(/^step_\d+_/, ''));
    });
    return out;
}

// ---- STEP 27 : analyse surélévation -----------------------------------------
// Parse le JSON complet et en extrait un rapport ciblé surélévation :
// parcelle + bâtiment existant, règles de hauteur, tous les passages du
// règlement PLU mentionnant la surélévation, contraintes patrimoniales et
// points de vigilance.

var SURELEV_RE = /sur[ée]l[eé]v/i;

function findStep(results, action) {
    var suffix = '_' + action;
    var key = Object.keys(results).find(function (k) { return k.slice(-suffix.length) === suffix; });
    var resp = key ? results[key] : null;
    return resp && resp.ok ? resp.data : null;
}

function collectMentions(text, source, store) {
    if (typeof text !== 'string' || !SURELEV_RE.test(text)) return;
    var lines = text.split('\n');
    var consumed = {};
    for (var i = 0; i < lines.length; i++) {
        if (consumed[i] || !SURELEV_RE.test(lines[i])) continue;
        var indent = lines[i].match(/^\s*/)[0].length;
        var chunk = [lines[i].trim()];
        consumed[i] = true;
        for (var j = i + 1; j < lines.length; j++) {
            if (!lines[j].trim()) break;
            if (lines[j].match(/^\s*/)[0].length <= indent) break;
            chunk.push(lines[j].trim());
            consumed[j] = true;
        }
        var texte = chunk.join('\n');
        var existing = store.find(function (m) { return m.texte === texte; });
        if (existing) {
            if (existing.sources.indexOf(source) === -1) existing.sources.push(source);
        } else {
            store.push({ sources: [source], texte: texte });
        }
    }
}

function walkBlocks(blocks, visit) {
    if (!blocks || typeof blocks !== 'object') return;
    Object.keys(blocks).forEach(function (bk) {
        if (bk === 'KEY_DATA') return;
        var block = blocks[bk] || {};
        var sections = block.value && typeof block.value === 'object' ? block.value : {};
        Object.keys(sections).forEach(function (sk) {
            var section = sections[sk] || {};
            var rubs = section.value && typeof section.value === 'object' ? section.value : {};
            Object.keys(rubs).forEach(function (rk) {
                var rub = rubs[rk];
                if (rub && typeof rub === 'object') {
                    visit(block.key || bk, section.key || sk, rk, rub);
                }
            });
        });
    });
}

function buildSurelevationReport(results) {
    var geocode = findStep(results, 'geocode');
    var units = findStep(results, 'parcel_units');
    var plu = findStep(results, 'airtable_plu');
    var psmv = findStep(results, 'psmv');
    var prevention = findStep(results, 'prevention_plans');

    var unit = Array.isArray(units) && units.length ? units[0] : (units || {});
    var parcel = unit.parcels && unit.parcels[0] ? unit.parcels[0] : {};
    var buildings = unit.buildings || {};
    var zone = unit.urban_zones && unit.urban_zones[0] ? unit.urban_zones[0] : null;
    var geoProps = geocode && geocode.properties ? geocode.properties : {};
    var geoCoords = (geocode && geocode.geometry && geocode.geometry.coordinates) || [];

    var pluInner = plu && plu.data && typeof plu.data === 'object' ? plu.data : {};
    var blocks = pluInner.blocks && typeof pluInner.blocks === 'object' ? pluInner.blocks : {};

    var psmvConcerne = false;
    if (Array.isArray(psmv)) psmvConcerne = psmv.length > 0;
    else if (psmv && typeof psmv === 'object') {
        psmvConcerne = psmv.concerned === true
            || (Array.isArray(psmv.zones) && psmv.zones.length > 0)
            || (Array.isArray(psmv.documents) && psmv.documents.length > 0);
    }

    var nbPlansPrevention = 0;
    if (Array.isArray(prevention)) nbPlansPrevention = prevention.length;
    else if (prevention && Array.isArray(prevention.items)) nbPlansPrevention = prevention.items.length;

    var report = {
        adresse: {
            label: geoProps.label || null,
            insee: geoProps.citycode || (unit.address && unit.address.inseeCode) || null,
            commune: geoProps.city || null,
            lat: geoCoords[1] != null ? geoCoords[1] : null,
            lon: geoCoords[0] != null ? geoCoords[0] : null,
        },
        parcelle: {
            id: parcel.id || (unit.address && unit.address.parcelle_id) || null,
            section: parcel.section || null,
            numero: parcel.number || null,
            surface_m2: parcel.surface != null ? Math.round(parcel.surface * 10) / 10 : null,
            surfaceFiscale_m2: parcel.surface_fisc != null ? parcel.surface_fisc : null,
            protegee: parcel.protected != null ? parcel.protected : null,
        },
        batimentExistant: {
            hauteur_m: buildings.high != null ? buildings.high : null,
            niveaux: buildings.floor != null ? buildings.floor : null,
            logements: buildings.housing != null ? buildings.housing : null,
            usages: buildings.usages || null,
            anneeConstruction: buildings.date || null,
            dpe: buildings.dpe || null,
            constructionLegere: buildings.has_light_construction != null ? buildings.has_light_construction : null,
        },
        zonePlu: Object.assign({}, zone || {}, pluInner.meta && typeof pluInner.meta === 'object' ? {
            specificite: pluInner.meta.specificity || null,
            approbation: pluInner.meta.approbationDate || null,
            lienReglement: pluInner.meta.link || null,
        } : {}),
        reglesCles: null,
        hauteurs: { dispositionsGenerales: [], casParticuliers: null },
        reglesSurelevation: [],
        contraintes: {
            prescriptions: [],
            servitudes: [],
            psmv: psmvConcerne,
            monumentsHistoriquesProximite: (unit.nearby && Array.isArray(unit.nearby.historic_monument)) ? unit.nearby.historic_monument.length : 0,
            plansPreventionRisques: nbPlansPrevention,
            preemption: unit.preemption || null,
        },
        pointsDeVigilance: [],
    };

    var keyData = blocks.KEY_DATA && blocks.KEY_DATA.value;
    if (keyData && typeof keyData === 'object') {
        var dc = {};
        Object.keys(keyData).forEach(function (k) {
            var entry = keyData[k] || {};
            dc[entry.key || k] = entry.value != null ? entry.value : null;
        });
        report.reglesCles = dc;
    }

    walkBlocks(blocks, function (blockKey, sectionKey, rubKey, rub) {
        var isHauteurs = /hauteur/i.test(blockKey);
        if (isHauteurs && rubKey === 'dispo-gen') {
            if (typeof rub.textSource === 'string' && rub.textSource.trim()) {
                report.hauteurs.dispositionsGenerales.push(rub.textSource.trim());
            }
        }
        if (isHauteurs && rubKey === 'dispo-parti' && typeof rub.resum === 'string' && !report.hauteurs.casParticuliers) {
            report.hauteurs.casParticuliers = rub.resum;
        }
        var ts = rub.textSource;
        var source = blockKey + ' > ' + sectionKey;
        if (typeof ts === 'string') {
            collectMentions(ts, source, report.reglesSurelevation);
        } else if (ts && typeof ts === 'object') {
            Object.keys(ts).forEach(function (tag) {
                collectMentions(ts[tag], source + ' ' + tag, report.reglesSurelevation);
            });
        }
    });

    (unit.prescriptions || []).forEach(function (p) {
        report.contraintes.prescriptions.push({ type: p.type || null, libelle: p.libelle || null });
    });
    (unit.servitudes || []).forEach(function (s) {
        report.contraintes.servitudes.push({ type: s.type || null, libelle: truncList(s.libelle, 3) });
    });

    var vigilance = report.pointsDeVigilance;
    function flag(msg) { if (vigilance.indexOf(msg) === -1) vigilance.push(msg); }

    report.contraintes.prescriptions.forEach(function (p) {
        var txt = (p.type || '') + ' ' + (Array.isArray(p.libelle) ? p.libelle.join(' ') : (p.libelle || ''));
        if (/prot[ée]g[ée]/i.test(txt)) flag('Bâtiment ou parcelle protégé(e) au PLU — surélévation fortement contrainte, étude patrimoniale à prévoir');
        if (/hauteur/i.test(txt)) flag('Prescription de hauteur sur la parcelle (' + (p.type || '') + ') — vérifier le plafond applicable avant tout projet');
    });
    report.contraintes.servitudes.forEach(function (s) {
        if (/monuments historiques|abords/i.test(s.type || '')) flag('Périmètre des abords de monuments historiques — avis de l\'Architecte des Bâtiments de France requis');
        if (/sites inscrits et class[ée]s/i.test(s.type || '')) flag('Site inscrit ou classé — autorisation spéciale nécessaire');
    });
    if (report.contraintes.psmv) flag('Parcelle en Plan de Sauvegarde et de Mise en Valeur (PSMV) — règlement spécifique prioritaire sur le PLU');
    if (report.contraintes.plansPreventionRisques > 0) flag('Plan(s) de prévention des risques applicable(s) — contraintes constructives possibles');
    if (parcel.protected === true) flag('Parcelle marquée protégée dans le cadastre iudo');

    // --- Immeubles mitoyens + calcul de surélévation --------------------------
    var mitoyensStep = results.step_25b_mitoyens;
    var mitoyens = (mitoyensStep && Array.isArray(mitoyensStep.data)) ? mitoyensStep.data : [];
    var mitoyensH = mitoyens.map(function (m) { return m.hauteur_m; })
        .filter(function (h) { return typeof h === 'number'; });
    var moyenneMitoyens = mitoyensH.length
        ? Math.round((mitoyensH.reduce(function (a, b) { return a + b; }, 0) / mitoyensH.length) * 10) / 10
        : null;

    report.mitoyens = {
        parcelles: mitoyens,
        nbAvecHauteur: mitoyensH.length,
        moyenneHauteur_m: moyenneMitoyens,
    };

    var existant = report.batimentExistant.hauteur_m;
    report.hauteursReglementaires = {
        note: 'Zone ' + (report.zonePlu.name || '?') + ' : la hauteur maximale résulte de règles cumulatives (gabarits-enveloppes, fuseaux, HMC). Valeur à retenir par l\'équipe parmi les hauteurs repérées ci-dessous et le règlement.',
        valeursRepereesM: extractRegHeights(blocks),
    };
    report.calcul = {
        hauteurExistante_m: existant,
        moyenneMitoyens_m: moyenneMitoyens,
        // Résultat 1 (potentiel brut) = hauteur réglementaire max − existant.
        // La hauteur réglementaire n'étant pas un scalaire unique en zone UG,
        // on fournit la formule et l'écart existant / moyenne mitoyens.
        formuleResultat1: 'Potentiel = Hauteur réglementaire max (à retenir) − ' + (existant != null ? existant + ' m (existant)' : 'hauteur existante'),
        formuleFinale: 'Résultat final = pondération du Résultat 1 avec la moyenne des immeubles mitoyens',
        ecartMoyenneMitoyens_m: (moyenneMitoyens != null && existant != null)
            ? Math.round((moyenneMitoyens - existant) * 10) / 10
            : null,
    };

    if (moyenneMitoyens != null && existant != null) {
        if (moyenneMitoyens > existant) {
            flag('Immeubles mitoyens plus hauts en moyenne (' + moyenneMitoyens + ' m vs ' + existant + ' m existant) — marge d\'harmonisation possible vers le haut (+' + report.calcul.ecartMoyenneMitoyens_m + ' m)');
        } else if (moyenneMitoyens < existant) {
            flag('Immeubles mitoyens plus bas en moyenne (' + moyenneMitoyens + ' m vs ' + existant + ' m existant) — surélévation susceptible de dépasser le bâti voisin, insertion urbaine à justifier');
        }
    } else if (!mitoyens.length) {
        flag('Aucun immeuble mitoyen exploitable — moyenne d\'harmonisation non calculable');
    }

    if (!vigilance.length) flag('Aucun signal patrimonial ou réglementaire bloquant détecté dans les données collectées');

    return report;
}

// Hauteurs en mètres citées dans les règles de hauteur (phrases mentionnant une
// hauteur maximale / plafond / gabarit / HMC), dédupliquées et triées.
function extractRegHeights(blocks) {
    var hauteurBlock = null;
    Object.keys(blocks || {}).forEach(function (bk) {
        if (/hauteur/i.test((blocks[bk] && blocks[bk].key) || bk)) hauteurBlock = blocks[bk];
    });
    if (!hauteurBlock) return [];
    var vals = {};
    var texts = [];
    (function walk(v) {
        if (typeof v === 'string') { texts.push(v); return; }
        if (Array.isArray(v)) { v.forEach(walk); return; }
        if (v && typeof v === 'object') { Object.keys(v).forEach(function (k) { walk(v[k]); }); }
    })(hauteurBlock.value);
    texts.forEach(function (txt) {
        txt.split(/(?<=[.;:])\s+|\n/).forEach(function (sentence) {
            if (!/hauteur|gabarit|plafond|HMC|limite/i.test(sentence)) return;
            var m, re = /(\d{1,2}(?:[.,]\d)?)\s*m(?:ètre|etre)?s?\b/g;
            while ((m = re.exec(sentence)) !== null) {
                var n = parseFloat(m[1].replace(',', '.'));
                if (n >= 6 && n <= 90) vals[n] = true;
            }
        });
    });
    return Object.keys(vals).map(Number).sort(function (a, b) { return a - b; });
}

function renderSurelevationStep() {
    var report;
    try { report = buildSurelevationReport(allResults); }
    catch (e) {
        logMsg('STEP 27 ERREUR — analyse surélévation : ' + esc(e.message), 'error');
        return;
    }
    var json = JSON.stringify(report, null, 2);
    logMsg('STEP 27 — Analyse surélévation : ' + report.reglesSurelevation.length + ' règle(s) trouvée(s), '
        + report.pointsDeVigilance.length + ' point(s) de vigilance — ' + json.length + ' bytes', 'ok');

    var box = document.createElement('div');
    box.className = 'df-step df-output open';
    box.innerHTML =
        '<div class="df-step-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<div><span class="df-step-title">STEP 27 — ANALYSE SURÉLÉVATION</span>' +
            '<span class="df-step-url">Extraction des infos et règles utiles à la surélévation</span></div>' +
            '<span class="df-step-meta">' + Math.round(json.length / 1024) + ' Ko</span>' +
            '<span class="df-dot ok"></span>' +
        '</div>' +
        '<div class="df-step-body">' +
            '<div class="df-output-actions">' +
                '<button id="df-show-sur" class="px-4 py-2 text-xs rounded-xl bg-gray-200 text-gray-700 hover:bg-gray-300">Afficher le JSON</button>' +
                '<button id="df-copy-sur" class="btn-cta text-xs px-4 py-2">Copier JSON</button>' +
                '<button id="df-dl-sur" class="px-4 py-2 text-xs rounded-xl bg-gray-600 text-white hover:bg-gray-700">Télécharger</button>' +
            '</div>' +
            '<pre class="df-raw-json" id="df-surelevation">' + esc(json) + '</pre>' +
        '</div>';
    box.querySelector('.df-step-head').addEventListener('click', function () {
        box.classList.toggle('open');
    });
    $steps.appendChild(box);

    document.getElementById('df-show-sur').addEventListener('click', function () {
        var open = document.getElementById('df-surelevation').classList.toggle('open');
        this.textContent = open ? 'Masquer le JSON' : 'Afficher le JSON';
    });

    document.getElementById('df-copy-sur').addEventListener('click', function () {
        navigator.clipboard.writeText(json).then(function () {
            var b = document.getElementById('df-copy-sur');
            b.textContent = 'Copié !';
            setTimeout(function () { b.textContent = 'Copier JSON'; }, 1500);
        });
    });
    document.getElementById('df-dl-sur').addEventListener('click', function () {
        var blob = new Blob([json], { type: 'application/json' });
        var a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = 'dataflow_' + (selected.insee || 'output') + '_surelevation.json';
        a.click();
        URL.revokeObjectURL(a.href);
    });
}

// ---- STEP 28 : rapport surélévation lisible (sans JSON) ---------------------
// Rapport destiné à l'équipe métier : mise en forme HTML, sections claires,
// calcul « hauteur réglementaire − existant » et moyenne des mitoyens en avant.
// Aucun JSON affiché ; l'équipe lit, trie et décide.

function fmtM(v) { return v == null ? '<span class="df-rep-na">non renseigné</span>' : String(v).replace('.', ',') + ' m'; }
function fmtVal(v) { return (v == null || v === '') ? '<span class="df-rep-na">non renseigné</span>' : esc(String(v)); }

function repTable(rows) {
    return '<table class="df-rep-table"><tbody>' + rows.map(function (r) {
        return '<tr><th>' + esc(r[0]) + '</th><td>' + (r[2] ? r[1] : fmtVal(r[1])) + '</td></tr>';
    }).join('') + '</tbody></table>';
}

function repSection(title, inner) {
    return '<section class="df-rep-section"><h4>' + esc(title) + '</h4>' + inner + '</section>';
}

function buildSurelevationHtml(rep) {
    var b = rep.batimentExistant, m = rep.mitoyens, c = rep.calcul;
    var html = '';

    // En-tête adresse
    html += '<div class="df-rep-addr">' +
        '<div class="df-rep-addr-main">' + fmtVal(rep.adresse.label) + '</div>' +
        '<div class="df-rep-addr-sub">Parcelle ' + fmtVal(rep.parcelle.id) +
        ' · INSEE ' + fmtVal(rep.adresse.insee) +
        ' · Zone PLU ' + fmtVal(rep.zonePlu.name) + (rep.zonePlu.libelong ? ' (' + esc(rep.zonePlu.libelong) + ')' : '') + '</div></div>';

    // A — Calcul de surélévation (headline)
    var calcRows =
        '<div class="df-rep-calc">' +
            '<div class="df-rep-calc-item"><span class="df-rep-calc-lbl">Hauteur existante</span><span class="df-rep-calc-val">' + fmtM(c.hauteurExistante_m) + '</span><span class="df-rep-calc-sub">' + (b.niveaux != null ? b.niveaux + ' niveaux' : '') + '</span></div>' +
            '<div class="df-rep-calc-op">+ ?</div>' +
            '<div class="df-rep-calc-item"><span class="df-rep-calc-lbl">Hauteur réglementaire max</span><span class="df-rep-calc-val df-rep-todo">à retenir</span><span class="df-rep-calc-sub">voir section D</span></div>' +
            '<div class="df-rep-calc-op">=</div>' +
            '<div class="df-rep-calc-item"><span class="df-rep-calc-lbl">Potentiel (Résultat 1)</span><span class="df-rep-calc-val">Régl. − ' + fmtM(c.hauteurExistante_m) + '</span><span class="df-rep-calc-sub">réglementaire − existant</span></div>' +
        '</div>' +
        '<p class="df-rep-formula"><strong>Résultat 1 :</strong> ' + esc(c.formuleResultat1) + '</p>' +
        '<p class="df-rep-formula"><strong>Résultat final :</strong> ' + esc(c.formuleFinale) + '</p>' +
        '<p class="df-rep-formula"><strong>Moyenne hauteur des immeubles mitoyens :</strong> ' + fmtM(c.moyenneMitoyens_m) +
            (c.ecartMoyenneMitoyens_m != null ? ' &nbsp;·&nbsp; écart avec l\'existant : ' + (c.ecartMoyenneMitoyens_m > 0 ? '+' : '') + String(c.ecartMoyenneMitoyens_m).replace('.', ',') + ' m' : '') + '</p>';
    html += repSection('A. Calcul de surélévation', calcRows);

    // B — Bâtiment existant
    html += repSection('B. Bâtiment existant', repTable([
        ['Hauteur', fmtM(b.hauteur_m), true],
        ['Niveaux', b.niveaux, false],
        ['Logements', b.logements, false],
        ['Usages', b.usages, false],
        ['Année de construction', b.anneeConstruction, false],
        ['DPE', b.dpe, false],
        ['Surface parcelle', rep.parcelle.surface_m2 != null ? fmtM(rep.parcelle.surface_m2).replace(' m', ' m²') : null, true],
    ]));

    // C — Immeubles mitoyens
    var mitInner;
    if (m.parcelles.length) {
        mitInner = '<table class="df-rep-table df-rep-grid"><thead><tr>' +
            '<th>Parcelle</th><th>Adresse</th><th>Hauteur</th><th>Niveaux</th><th>Usages</th><th>Année</th></tr></thead><tbody>' +
            m.parcelles.map(function (n) {
                if (n.error) return '<tr><td>' + fmtVal(n.parcelId) + '</td><td colspan="5" class="df-rep-na">' + esc(n.error) + '</td></tr>';
                return '<tr><td>' + fmtVal(n.parcelId) + '</td><td>' + fmtVal(n.adresse) + '</td><td>' + fmtM(n.hauteur_m) +
                    '</td><td>' + fmtVal(n.niveaux) + '</td><td>' + fmtVal(n.usages) + '</td><td>' + fmtVal(n.anneeConstruction) + '</td></tr>';
            }).join('') +
            '</tbody></table>' +
            '<p class="df-rep-note">Moyenne des hauteurs mitoyennes : <strong>' + fmtM(m.moyenneHauteur_m) + '</strong> (' + m.nbAvecHauteur + ' immeuble(s) avec hauteur sur ' + m.parcelles.length + ')</p>';
    } else {
        mitInner = '<p class="df-rep-na">Aucune parcelle mitoyenne renvoyée par l\'API.</p>';
    }
    html += repSection('C. Immeubles mitoyens', mitInner);

    // D — Hauteurs réglementaires
    var hr = rep.hauteursReglementaires || {};
    var dInner = '<p class="df-rep-note">' + esc(hr.note || '') + '</p>';
    if (hr.valeursRepereesM && hr.valeursRepereesM.length) {
        dInner += '<p>Hauteurs repérées dans les règles de hauteur : ' +
            hr.valeursRepereesM.map(function (v) { return '<span class="df-rep-chip">' + String(v).replace('.', ',') + ' m</span>'; }).join(' ') + '</p>';
    }
    if (rep.hauteurs.dispositionsGenerales.length) {
        dInner += '<div class="df-rep-rules">' + rep.hauteurs.dispositionsGenerales.map(function (t) {
            return '<p>' + esc(t) + '</p>';
        }).join('') + '</div>';
    }
    if (rep.reglesCles) {
        var kc = Object.keys(rep.reglesCles).map(function (k) { return [k, rep.reglesCles[k], false]; });
        dInner += '<h5 class="df-rep-h5">Données clés du règlement</h5>' + repTable(kc);
    }
    html += repSection('D. Hauteurs réglementaires (PLU)', dInner);

    // E — Règles spécifiques surélévation
    var eInner = rep.reglesSurelevation.length
        ? '<ul class="df-rep-list">' + rep.reglesSurelevation.map(function (r) {
            return '<li><span class="df-rep-src">' + esc(r.sources.join(', ')) + '</span>' + esc(r.texte) + '</li>';
          }).join('') + '</ul>'
        : '<p class="df-rep-na">Aucune règle mentionnant explicitement la surélévation.</p>';
    html += repSection('E. Règles spécifiques à la surélévation (' + rep.reglesSurelevation.length + ')', eInner);

    // F — Contraintes & vigilance
    var ct = rep.contraintes;
    var fInner = '<ul class="df-rep-vigilance">' + rep.pointsDeVigilance.map(function (v) {
        return '<li>' + esc(v) + '</li>';
    }).join('') + '</ul>';
    fInner += repTable([
        ['Prescriptions PLU', ct.prescriptions.length, false],
        ['Servitudes', ct.servitudes.length, false],
        ['PSMV', ct.psmv ? 'Oui' : 'Non', false],
        ['Monuments historiques à proximité', ct.monumentsHistoriquesProximite, false],
        ['Plans de prévention des risques', ct.plansPreventionRisques, false],
    ]);
    if (ct.prescriptions.length) {
        fInner += '<h5 class="df-rep-h5">Prescriptions</h5><ul class="df-rep-list">' +
            ct.prescriptions.map(function (p) {
                return '<li><span class="df-rep-src">' + fmtVal(p.type) + '</span>' + esc(Array.isArray(p.libelle) ? p.libelle.join(' · ') : (p.libelle || '')) + '</li>';
            }).join('') + '</ul>';
    }
    html += repSection('F. Contraintes & points de vigilance', fInner);

    return html;
}

function reportToText(rep) {
    var b = rep.batimentExistant, m = rep.mitoyens, c = rep.calcul, L = [];
    L.push('RAPPORT SURÉLÉVATION — ' + (rep.adresse.label || ''));
    L.push('Parcelle ' + (rep.parcelle.id || '') + ' · Zone PLU ' + (rep.zonePlu.name || '') + '\n');
    L.push('A. CALCUL DE SURÉLÉVATION');
    L.push('  Hauteur existante : ' + (c.hauteurExistante_m != null ? c.hauteurExistante_m + ' m' : '—') + (b.niveaux != null ? ' (' + b.niveaux + ' niveaux)' : ''));
    L.push('  ' + c.formuleResultat1);
    L.push('  ' + c.formuleFinale);
    L.push('  Moyenne hauteur mitoyens : ' + (c.moyenneMitoyens_m != null ? c.moyenneMitoyens_m + ' m' : '—') + (c.ecartMoyenneMitoyens_m != null ? ' (écart existant : ' + (c.ecartMoyenneMitoyens_m > 0 ? '+' : '') + c.ecartMoyenneMitoyens_m + ' m)' : '') + '\n');
    L.push('B. BÂTIMENT EXISTANT');
    L.push('  Hauteur ' + (b.hauteur_m != null ? b.hauteur_m + ' m' : '—') + ' · ' + (b.niveaux != null ? b.niveaux + ' niveaux' : '—') + ' · ' + (b.logements != null ? b.logements + ' logements' : '—') + ' · ' + (b.usages || '—') + ' · ' + (b.anneeConstruction || '—') + ' · DPE ' + (b.dpe || '—') + '\n');
    L.push('C. IMMEUBLES MITOYENS');
    if (m.parcelles.length) {
        m.parcelles.forEach(function (n) {
            L.push('  ' + n.parcelId + ' : ' + (n.error ? n.error : ((n.hauteur_m != null ? n.hauteur_m + ' m' : '—') + ' · ' + (n.niveaux != null ? n.niveaux + ' niv.' : '—') + ' · ' + (n.usages || '—') + ' · ' + (n.anneeConstruction || '—'))));
        });
        L.push('  → Moyenne : ' + (m.moyenneHauteur_m != null ? m.moyenneHauteur_m + ' m' : '—') + ' (' + m.nbAvecHauteur + '/' + m.parcelles.length + ')');
    } else { L.push('  Aucune parcelle mitoyenne.'); }
    L.push('');
    L.push('D. HAUTEURS RÉGLEMENTAIRES');
    L.push('  ' + (rep.hauteursReglementaires.note || ''));
    if (rep.hauteursReglementaires.valeursRepereesM.length) L.push('  Valeurs repérées : ' + rep.hauteursReglementaires.valeursRepereesM.map(function (v) { return v + ' m'; }).join(', '));
    L.push('');
    L.push('E. RÈGLES SURÉLÉVATION (' + rep.reglesSurelevation.length + ')');
    rep.reglesSurelevation.forEach(function (r) { L.push('  [' + r.sources.join(', ') + '] ' + r.texte.replace(/\n/g, ' ')); });
    L.push('');
    L.push('F. CONTRAINTES & VIGILANCE');
    rep.pointsDeVigilance.forEach(function (v) { L.push('  - ' + v); });
    return L.join('\n');
}

function renderSurelevationReportStep() {
    var rep;
    try { rep = buildSurelevationReport(allResults); }
    catch (e) {
        logMsg('STEP 28 ERREUR — rapport surélévation : ' + esc(e.message), 'error');
        return;
    }
    logMsg('STEP 28 — Rapport surélévation métier généré', 'ok');

    var box = document.createElement('div');
    box.className = 'df-step df-output df-report-step open';
    box.innerHTML =
        '<div class="df-step-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<div><span class="df-step-title">STEP 28 — RAPPORT SURÉLÉVATION (métier)</span>' +
            '<span class="df-step-url">Synthèse lisible pour tri et décision — sans JSON</span></div>' +
            '<span class="df-todo" title="Brancher le calcul de hauteur autorisée (filets plub_filet / largeur de voie) puis le moteur économique">TODO</span>' +
            '<span class="df-dot ok"></span>' +
        '</div>' +
        '<div class="df-step-body">' +
            '<div class="df-output-actions">' +
                '<button id="df-copy-rep" class="btn-cta text-xs px-4 py-2">Copier le rapport (texte)</button>' +
                '<button id="df-print-rep" class="px-4 py-2 text-xs rounded-xl bg-gray-600 text-white hover:bg-gray-700">Imprimer / PDF</button>' +
            '</div>' +
            '<div class="df-report" id="df-report">' + buildSurelevationHtml(rep) + '</div>' +
        '</div>';
    box.querySelector('.df-step-head').addEventListener('click', function () {
        box.classList.toggle('open');
    });
    $steps.appendChild(box);

    document.getElementById('df-copy-rep').addEventListener('click', function () {
        navigator.clipboard.writeText(reportToText(rep)).then(function () {
            var bt = document.getElementById('df-copy-rep');
            bt.textContent = 'Copié !';
            setTimeout(function () { bt.textContent = 'Copier le rapport (texte)'; }, 1500);
        });
    });
    document.getElementById('df-print-rep').addEventListener('click', function () {
        var w = window.open('', '_blank');
        if (!w) return;
        w.document.write('<html><head><title>Rapport surélévation — ' + esc(rep.adresse.label || '') + '</title>' +
            '<style>body{font-family:system-ui,sans-serif;margin:32px;color:#111;line-height:1.5}' +
            'h4{margin:22px 0 8px;font-size:16px;border-bottom:2px solid #16a34a;padding-bottom:4px}' +
            'h5{margin:14px 0 6px;font-size:13px}table{border-collapse:collapse;width:100%;margin:6px 0}' +
            'th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;font-size:13px;vertical-align:top}' +
            'th{background:#f3f4f6;width:220px}.df-rep-grid th{width:auto}ul{margin:6px 0;padding-left:20px}' +
            'li{margin:4px 0;font-size:13px}.df-rep-chip{display:inline-block;background:#dcfce7;padding:2px 8px;border-radius:10px;margin:2px;font-size:12px}' +
            '.df-rep-src{color:#6b7280;font-size:11px;display:block}.df-rep-na{color:#9ca3af}' +
            '.df-rep-calc{display:flex;gap:10px;flex-wrap:wrap;margin:10px 0}.df-rep-calc-item{border:1px solid #ddd;border-radius:8px;padding:8px 12px}' +
            '.df-rep-addr-main{font-size:18px;font-weight:700}.df-rep-formula{margin:4px 0;font-size:13px}</style></head><body>' +
            '<div class="df-rep-addr"><div class="df-rep-addr-main">' + esc(rep.adresse.label || '') + '</div></div>' +
            buildSurelevationHtml(rep) + '</body></html>');
        w.document.close();
        w.focus();
        w.print();
    });
}

function renderOutputStep() {
    var box = document.createElement('div');
    box.className = 'df-step df-output open';
    var fullJson = JSON.stringify(allResults, null, 2);
    var compactJson = JSON.stringify(buildCompactOutput(allResults), null, 2);
    logMsg('Output final : ' + compactJson.length + ' bytes (compact) — ' + fullJson.length + ' bytes (complet)', 'info');
    box.innerHTML =
        '<div class="df-step-head">' +
            '<span class="df-caret">&#9654;</span>' +
            '<div><span class="df-step-title">STEP 26 — OUTPUT FINAL</span>' +
            '<span class="df-step-url">Synthèse compacte — le JSON complet reste téléchargeable</span></div>' +
            '<span class="df-step-meta">' + Math.round(compactJson.length / 1024) + ' Ko</span>' +
            '<span class="df-dot ok"></span>' +
        '</div>' +
        '<div class="df-step-body">' +
            '<div class="df-output-actions">' +
                '<button id="df-show" class="px-4 py-2 text-xs rounded-xl bg-gray-200 text-gray-700 hover:bg-gray-300">Afficher le JSON</button>' +
                '<button id="df-copy" class="btn-cta text-xs px-4 py-2">Copier JSON</button>' +
                '<button id="df-dl" class="px-4 py-2 text-xs rounded-xl bg-gray-600 text-white hover:bg-gray-700">Télécharger</button>' +
                '<button id="df-dl-full" class="px-4 py-2 text-xs rounded-xl bg-gray-400 text-white hover:bg-gray-500">Télécharger complet</button>' +
            '</div>' +
            '<pre class="df-raw-json" id="df-final">' + esc(compactJson) + '</pre>' +
        '</div>';
    box.querySelector('.df-step-head').addEventListener('click', function () {
        box.classList.toggle('open');
    });
    $steps.appendChild(box);

    document.getElementById('df-show').addEventListener('click', function () {
        var open = document.getElementById('df-final').classList.toggle('open');
        this.textContent = open ? 'Masquer le JSON' : 'Afficher le JSON';
    });

    function download(json, suffix) {
        var blob = new Blob([json], { type: 'application/json' });
        var a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = 'dataflow_' + (selected.insee || 'output') + suffix + '.json';
        a.click();
        URL.revokeObjectURL(a.href);
    }

    document.getElementById('df-copy').addEventListener('click', function () {
        navigator.clipboard.writeText(compactJson).then(function () {
            var b = document.getElementById('df-copy');
            b.textContent = 'Copié !';
            setTimeout(function () { b.textContent = 'Copier JSON'; }, 1500);
        });
    });
    document.getElementById('df-dl').addEventListener('click', function () { download(compactJson, ''); });
    document.getElementById('df-dl-full').addEventListener('click', function () { download(fullJson, '_complet'); });

    updateAuditCard();        // remplit la fiche d'audit gratuite sur la carte
    addSurelevationBlock(0);  // pose le volume de surélévation (vert/gris) sur le toit
}

function setGlobalStatus(text, state) {
    $globalStatus.textContent = text;
    var colors = { running: '#f59e0b', ok: '#22c55e', error: '#ef4444' };
    $globalStatus.style.color = colors[state] || '';
}

function buildDisplayUrl(action, params, method) {
    var map = {
        login:            function () { return 'POST /auth/log-in'; },
        parcels:          function (p) { return 'GET /parcels?lat=' + p.lat + '&lon=' + p.lon; },
        parcel_detail:    function (p) { return 'GET /parcels/' + p.parcelId; },
        parcel_coords:    function (p) { return 'GET /parcels/' + p.parcelId + '/coordinates'; },
        urban_zones:      function (p) { return 'GET /urban_zones/zones/' + p.insee; },
        parcel_units:     function (p) { return 'GET /parcels/' + p.parcelId + '/units?no_adu='; },
        airtable_plu:     function (p) { return 'POST /airtable/plu/' + p.zoneId; },
        building_rights:  function (p) { return 'GET /airtable/plu/' + p.zoneId + '/building-rights'; },
        town_psc:         function (p) { return 'GET /town/psc/' + p.insee; },
        docurba:          function (p) { return 'GET /docurba/urban-zone-type/' + p.insee; },
        gpu_plu_info:     function (p) { return 'GET /geoportail-urbanisme/plu-info-insee/' + p.insee; },
        psmv:             function (p) { return 'GET /psmv/parcels/' + p.parcelId; },
        districts:        function (p) { return 'GET /districts/insee/' + p.insee; },
        dvf_stats:        function (p) { return 'GET /dvf/sales-stats/' + p.insee + '?startYear=2020&endYear=2025'; },
        dvf_series:       function (p) { return 'GET /dvf/sales-stats-series/' + p.insee + '?startYear=2020&endYear=2025'; },
        dvf_parcels:      function (p) { return 'GET /dvf/sales/parcels/' + p.parcelId; },
        loyers:           function (p) { return 'GET /loyers/2025/' + p.insee; },
        adu_totals:       function (p) { return 'GET /adu/totals/' + p.insee; },
        adu_parcels:      function (p) { return 'GET /adu/parcels/' + p.parcelId; },
        adu_insee:         function (p) { return 'GET /adu/' + p.insee; },
        prevention_plans: function (p) { return 'GET /sup-surf/prevention-plans/parcels/' + p.parcelId; },
        georisques:       function (p) { return 'GET /georisques/address-risks?lon=' + p.lon + '&lat=' + p.lat + '&codeInsee=' + p.insee; },
        analytics:        function () { return 'POST /analytics/add'; },
        search_count:     function () { return 'POST /user/search-count'; },
    };
    var fn = map[action];
    return fn ? fn(params || {}) : method + ' ' + action;
}

function kv(k, v) {
    return '<span class="df-k">' + esc(k) + '=</span><span class="df-v">' + esc(String(v)) + '</span>';
}

function firstParcel(data) {
    if (Array.isArray(data)) return data[0] || null;
    if (data && Array.isArray(data.parcels)) return data.parcels[0] || null;
    if (data && Array.isArray(data.data)) return data.data[0] || null;
    if (data && typeof data === 'object') return data;
    return null;
}

function extractUrbanZone(data) {
    var arr = Array.isArray(data) ? data : (data && data.data) || [];
    var first = Array.isArray(arr) ? arr[0] : arr;
    if (!first) return null;
    var zones = first.urban_zones || first.urbanZones || [];
    return Array.isArray(zones) && zones.length ? zones[0] : null;
}

function countItems(data) {
    if (data == null) return 0;
    if (Array.isArray(data)) return data.length;
    if (typeof data === 'object') return Object.keys(data).length;
    return 1;
}

function byteSize(resp) {
    try {
        var src = resp.data != null ? resp.data : (resp.raw || '');
        return new Blob([typeof src === 'string' ? src : JSON.stringify(src)]).size;
    } catch (e) { return 0; }
}

function describeShort(data) {
    if (data == null) return 'réponse vide';
    if (Array.isArray(data)) return data.length + ' élément(s)';
    if (typeof data === 'object') {
        var keys = Object.keys(data).slice(0, 4).join(', ');
        return keys ? 'clés: ' + esc(keys) : 'objet vide';
    }
    return esc(String(data));
}

function esc(s) {
    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

// ============================================================================
// BATCH — test de masse (onglet Batch)
// ============================================================================
// Rejoue le flow headless (sans rendu d'étapes ni minimap) sur N adresses,
// puis liste les outputs dans un tableau paginé avec statut et téléchargements.

var $batchInput    = document.getElementById('df-address'); // champ unique partagé
var $batchRun      = document.getElementById('df-run'); // bouton commun
var $batchCancel   = document.getElementById('batch-cancel');
var $batchCompile  = document.getElementById('batch-compile');
var $batchProgress = document.getElementById('batch-progress');
var $batchTable    = document.getElementById('batch-table');
var $batchPager    = document.getElementById('batch-pager');

var BATCH_MAX = 100;
var BATCH_CONCURRENCY = 3;
var BATCH_PAGE_SIZE = 10;

var batchRows = [];       // [{ label, insee, feature, allResults, steps, status }]
var batchPage = 1;
var batchCancelled = false;

// Appel proxy headless (pas de rendu). Renvoie la réponse JSON parsée.
async function callProxy(action, method, params, body) {
    method = method || 'GET';
    var qs = new URLSearchParams(Object.assign({ action: action }, params || {})).toString();
    var proxyUrl = PROXY + '?' + qs;
    if (forceLiveOnce || ($nocache && $nocache.checked)) proxyUrl += '&nocache=1';
    try {
        var fetchOpts = { method: method, headers: { 'X-Admin-Key': PROXY_KEY } };
        if (method === 'POST') {
            fetchOpts.headers['Content-Type'] = 'application/json';
            fetchOpts.body = body != null ? JSON.stringify(body) : '';
        }
        var res = await timedFetch(proxyUrl, fetchOpts, 30000);
        var text = await res.text();
        try { return JSON.parse(text); }
        catch (e) { return { ok: false, status: res.status, action: action, error: 'Réponse proxy non-JSON', raw: text }; }
    } catch (err) {
        var msg = err.name === 'AbortError' ? 'Timeout (30s)' : err.message;
        return { ok: false, status: 0, action: action, error: 'Erreur réseau : ' + msg, raw: '' };
    }
}

// Géocode une adresse texte via la BAN. Renvoie la 1re feature ou null.
async function geocodeOne(query) {
    try {
        var res = await timedFetch(GEOCODE_URL + '?q=' + encodeURIComponent(query) + '&limit=1', {}, 15000);
        if (!res.ok) return null;
        var json = await res.json();
        return (json.features && json.features[0]) || null;
    } catch (e) { return null; }
}

// Rejoue le flow complet sur une adresse, sans DOM. Renvoie { allResults, steps }.
async function runAddressHeadless(sel) {
    var allResults = {};
    var steps = [];
    allResults.step_1_geocode = { ok: true, status: 200, action: 'geocode', data: sel._feature };
    var ctx = { lat: sel.lat, lon: sel.lon, insee: sel.insee, commune: sel.commune, label: sel.label };

    var login = await callProxy('login', 'GET', {}, null);
    allResults.step_2_login = login;
    if (login && login.ok && LOGIN_STEP.extract) { try { LOGIN_STEP.extract(login.data, ctx); } catch (e) { /* ignore */ } }
    steps.push({ num: 2, label: LOGIN_STEP.label, ok: !!(login && login.ok), error: login && !login.ok ? (login.error || 'HTTP ' + login.status) : null });

    for (var i = 0; i < STEPS.length; i++) {
        if (batchCancelled) break;
        var def = STEPS[i];
        if (def.requires && !ctx[def.requires]) {
            allResults['step_' + def.num] = { skipped: true, reason: def.requires };
            steps.push({ num: def.num, label: def.label, skipped: true, reason: def.requires });
            continue;
        }
        var swappedB = applyNationalSwap(def, def.build(ctx), ctx);
        var spec = swappedB.spec;
        var resp = await callProxy(spec.action, spec.method || 'GET', spec.params || {}, spec.body !== undefined ? spec.body : null);
        allResults['step_' + def.num + '_' + spec.action] = resp;
        if (resp && resp.ok && swappedB.extract) { try { swappedB.extract(resp.data, ctx); } catch (e) { /* ignore */ } }
        steps.push({ num: def.num, label: def.label, ok: !!(resp && resp.ok), error: resp && !resp.ok ? (resp.error || 'HTTP ' + resp.status) : null });
    }

    // Immeubles mitoyens (headless, réutilise fetchNeighborUnit).
    var neighbors = Array.isArray(ctx.neighbors) ? ctx.neighbors : [];
    var mitoyens = [];
    for (var n = 0; n < neighbors.length && !batchCancelled; n++) {
        mitoyens.push(await fetchNeighborUnit(neighbors[n]));
    }
    allResults.step_25b_mitoyens = { ok: true, action: 'mitoyens', data: mitoyens };

    return { allResults: allResults, steps: steps };
}

// Statut : rouge si étape critique KO, orange si erreurs/ignorés, vert sinon.
// Copie inline de src/lib/batch.mjs (testé) — garder les deux synchronisées.
function batchStatusOf(allResults, steps) {
    var errors = steps.filter(function (s) { return s.error; }).map(function (s) { return 'STEP ' + s.num + ' ' + s.label + ' : ' + s.error; });
    var skips = steps.filter(function (s) { return s.skipped; }).map(function (s) { return 'STEP ' + s.num + ' ' + s.label + ' : ignoré (' + s.reason + ')'; });
    var loginOk = allResults.step_2_login && allResults.step_2_login.ok;
    var parcelsOk = allResults.step_3_parcels && allResults.step_3_parcels.ok;
    var color = 'green';
    if (!loginOk || !parcelsOk) color = 'red';
    else if (errors.length || skips.length) color = 'orange';
    var lines = errors.concat(skips);
    return { color: color, title: lines.length ? lines.join('\n') : 'Toutes les étapes OK', errors: errors.length, skips: skips.length };
}

function downloadJson(obj, filename) {
    var blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
    var a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    URL.revokeObjectURL(a.href);
}

function batchSlug(row, idx) {
    return (row.insee || 'adr') + '_' + (idx + 1);
}

// Fenêtre de pagination : max 5 numéros, avec « … » (ex. 1 2 3 … 9 10).
// Copie inline de src/lib/batch.mjs (testé) — garder les deux synchronisées.
function pagerTokens(cur, totalPages) {
    if (totalPages <= 7) {
        var all = [];
        for (var p = 1; p <= totalPages; p++) all.push(p);
        return all;
    }
    var set = {};
    [1, totalPages, cur, cur - 1, cur + 1].forEach(function (p) { if (p >= 1 && p <= totalPages) set[p] = true; });
    // Complète pour toujours montrer ~5 numéros.
    if (cur <= 3) { [2, 3, 4].forEach(function (p) { set[p] = true; }); }
    if (cur >= totalPages - 2) { [totalPages - 1, totalPages - 2, totalPages - 3].forEach(function (p) { if (p >= 1) set[p] = true; }); }
    var nums = Object.keys(set).map(Number).sort(function (a, b) { return a - b; });
    var tokens = [];
    for (var k = 0; k < nums.length; k++) {
        if (k > 0 && nums[k] - nums[k - 1] > 1) tokens.push('…');
        tokens.push(nums[k]);
    }
    return tokens;
}

function renderBatchTable() {
    if (!$batchTable) return;
    if (!batchRows.length) { $batchTable.innerHTML = ''; if ($batchPager) $batchPager.innerHTML = ''; return; }

    var totalPages = Math.max(1, Math.ceil(batchRows.length / BATCH_PAGE_SIZE));
    if (batchPage > totalPages) batchPage = totalPages;
    var start = (batchPage - 1) * BATCH_PAGE_SIZE;
    var pageRows = batchRows.slice(start, start + BATCH_PAGE_SIZE);

    var head = '<table class="df-batch-table"><thead><tr>' +
        '<th>#</th><th>Adresse</th><th>Statut</th><th>Téléchargements</th></tr></thead><tbody>';
    var body = pageRows.map(function (row, j) {
        var idx = start + j;
        var st = row.status;
        var statusCell = row.pending
            ? '<span class="df-bstat df-bstat-run" title="En cours…"></span>'
            : '<span class="df-bstat df-bstat-' + st.color + '" title="' + esc(st.title) + '">' +
                (st.color === 'green' ? '&#10003;' : st.color === 'orange' ? '!' : '&#10007;') + '</span>';
        var dl = row.allResults
            ? '<button class="df-bdl" data-act="complet" data-idx="' + idx + '">Complet</button>' +
              '<button class="df-bdl" data-act="simple" data-idx="' + idx + '">Simple</button>' +
              '<button class="df-bdl" data-act="surelevation" data-idx="' + idx + '">Surélévation</button>'
            : '<span class="text-muted">—</span>';
        return '<tr><td>' + (idx + 1) + '</td>' +
            '<td>' + esc(row.label) + (row.error ? ' <span class="df-bstat df-bstat-red" title="' + esc(row.error) + '">&#10007;</span>' : '') + '</td>' +
            '<td>' + statusCell + '</td>' +
            '<td class="df-batch-dl">' + dl + '</td></tr>';
    }).join('');
    $batchTable.innerHTML = head + body + '</tbody></table>';

    $batchTable.querySelectorAll('.df-bdl').forEach(function (b) {
        b.addEventListener('click', function () {
            var row = batchRows[parseInt(b.dataset.idx, 10)];
            if (!row || !row.allResults) return;
            var slug = batchSlug(row, parseInt(b.dataset.idx, 10));
            if (b.dataset.act === 'complet') downloadJson(row.allResults, 'dataflow_' + slug + '_complet.json');
            else if (b.dataset.act === 'simple') downloadJson(buildCompactOutput(row.allResults), 'dataflow_' + slug + '_simple.json');
            else downloadJson(buildSurelevationReport(row.allResults), 'dataflow_' + slug + '_surelevation.json');
        });
    });

    renderBatchPager(totalPages);
}

function renderBatchPager(totalPages) {
    if (!$batchPager) return;
    if (totalPages <= 1) { $batchPager.innerHTML = ''; return; }
    var btn = function (label, page, disabled, active) {
        return '<button class="df-page' + (active ? ' active' : '') + '"' +
            (disabled ? ' disabled' : ' data-page="' + page + '"') + '>' + label + '</button>';
    };
    var html = btn('&laquo;', 1, batchPage === 1) + btn('&lsaquo;', batchPage - 1, batchPage === 1);
    pagerTokens(batchPage, totalPages).forEach(function (t) {
        html += t === '…' ? '<span class="df-page-ell">…</span>' : btn(String(t), t, false, t === batchPage);
    });
    html += btn('&rsaquo;', batchPage + 1, batchPage === totalPages) + btn('&raquo;', totalPages, batchPage === totalPages);
    $batchPager.innerHTML = html;
    $batchPager.querySelectorAll('.df-page[data-page]').forEach(function (b) {
        b.addEventListener('click', function () { batchPage = parseInt(b.dataset.page, 10); renderBatchTable(); });
    });
}

async function runBatch() {
    if (!$batchInput) return;
    var addresses = $batchInput.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean).slice(0, BATCH_MAX);
    if (!addresses.length) { $batchProgress.textContent = 'Aucune adresse.'; return; }
    await fetchSources(); // switches nationaux à jour pour le rejeu headless

    batchRows = addresses.map(function (a) { return { label: a, pending: true, allResults: null }; });
    batchPage = 1;
    batchCancelled = false;
    $batchRun.disabled = true;
    $batchCancel.hidden = false;
    $batchCompile.hidden = true;
    renderBatchTable();

    var done = 0;
    var updateProgress = function () { $batchProgress.textContent = done + ' / ' + batchRows.length + ' traitée(s)'; };
    updateProgress();

    // Pool de concurrence : BATCH_CONCURRENCY adresses en parallèle.
    var next = 0;
    async function worker() {
        while (!batchCancelled) {
            var idx = next++;
            if (idx >= batchRows.length) return;
            var row = batchRows[idx];
            var feat = await geocodeOne(row.label);
            if (!feat || !feat.geometry) {
                row.pending = false;
                row.error = 'Adresse introuvable (géocodage)';
                row.status = { color: 'red', title: row.error };
            } else {
                var p = feat.properties || {};
                var coords = feat.geometry.coordinates;
                var sel = { lat: coords[1], lon: coords[0], insee: p.citycode || '', commune: p.city || '', label: p.label || row.label, _feature: feat };
                row.label = sel.label;
                row.insee = sel.insee;
                var out = await runAddressHeadless(sel);
                row.allResults = out.allResults;
                row.steps = out.steps;
                row.status = batchStatusOf(out.allResults, out.steps);
                row.pending = false;
            }
            done++;
            updateProgress();
            renderBatchTable();
        }
    }
    var pool = [];
    for (var w = 0; w < Math.min(BATCH_CONCURRENCY, batchRows.length); w++) pool.push(worker());
    await Promise.all(pool);

    $batchRun.disabled = false;
    $batchCancel.hidden = true;
    $batchCompile.hidden = batchRows.every(function (r) { return !r.allResults; });
    $batchProgress.textContent = done + ' / ' + batchRows.length + ' traitée(s)' + (batchCancelled ? ' — arrêté' : ' — terminé');
}

function compileSimples() {
    var out = batchRows.filter(function (r) { return r.allResults; }).map(function (r, i) {
        return { adresse: r.label, insee: r.insee || null, statut: r.status ? r.status.color : null, output: buildCompactOutput(r.allResults) };
    });
    downloadJson({ generatedAt: new Date().toISOString(), count: out.length, adresses: out }, 'batch_compilation_simple.json');
}

// Le lancement passe par runFlowOrBatch (bouton commun #df-run) : pas de
// listener direct ici, sinon double déclenchement.
if ($batchCancel) $batchCancel.addEventListener('click', function () { batchCancelled = true; });
if ($batchCompile) $batchCompile.addEventListener('click', compileSimples);

// ---- Gestion des clés API tierces (onglet POC) ------------------------------
// Formulaire de saisie masquée. Les valeurs partent au serveur (stockage
// persistant) et ne reviennent jamais en clair : seul un aperçu masqué est
// affiché.

var $keysList = document.getElementById('keys-list');

function keyStatusHtml(k) {
    return k.set
        ? '<span class="adm-key-set">&#10003; Enregistrée (' + esc(k.hint) + ')</span>'
        : '<span class="adm-key-unset">Non renseignée</span>';
}

function renderKeys() {
    if (!$keysList) return;
    timedFetch(PROXY + '?action=keys_get', { headers: { 'X-Admin-Key': PROXY_KEY } }, 8000)
        .then(function (res) { return res.json(); })
        .then(function (j) {
            if (!j || !j.ok || !Array.isArray(j.keys)) throw new Error('réponse invalide');
            $keysList.innerHTML = j.keys.map(function (k) {
                return '<div class="adm-key-row" data-name="' + esc(k.name) + '">' +
                    '<label>' + esc(k.label) + '</label>' +
                    '<div class="adm-key-controls">' +
                        '<input type="password" autocomplete="off" spellcheck="false" ' +
                        'placeholder="' + (k.set ? 'Nouvelle valeur pour remplacer…' : 'Coller la clé…') + '" />' +
                        '<button type="button" class="adm-key-save">Enregistrer</button>' +
                    '</div>' +
                    '<div class="adm-key-status">' + keyStatusHtml(k) + '</div>' +
                '</div>';
            }).join('');
            $keysList.querySelectorAll('.adm-key-row').forEach(function (row) {
                row.querySelector('.adm-key-save').addEventListener('click', function () {
                    saveKey(row);
                });
            });
        })
        .catch(function () {
            $keysList.innerHTML = '<p class="text-muted text-sm">Impossible de charger les clés (serveur injoignable).</p>';
        });
}

function saveKey(row) {
    var name = row.getAttribute('data-name');
    var input = row.querySelector('input');
    var status = row.querySelector('.adm-key-status');
    var btn = row.querySelector('.adm-key-save');
    var value = input.value;
    btn.disabled = true;
    btn.textContent = '…';
    timedFetch(PROXY + '?action=keys_set', {
        method: 'POST',
        headers: { 'X-Admin-Key': PROXY_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: name, value: value }),
    }, 8000)
        .then(function (res) { return res.json(); })
        .then(function (j) {
            if (!j || !j.ok) throw new Error('échec');
            input.value = '';
            status.innerHTML = keyStatusHtml(j);
            btn.textContent = value === '' ? 'Supprimée' : 'Enregistrée !';
            setTimeout(function () { btn.textContent = 'Enregistrer'; btn.disabled = false; }, 1500);
        })
        .catch(function () {
            btn.textContent = 'Erreur';
            setTimeout(function () { btn.textContent = 'Enregistrer'; btn.disabled = false; }, 1500);
        });
}

if ($keysList) renderKeys();

// ---- Sources de données : switches iudo -> APIs nationales (onglet API) -----

var $sourcesList = document.getElementById('sources-list');

function renderSources() {
    if (!$sourcesList) return;
    timedFetch(PROXY + '?action=sources_get', { headers: { 'X-Admin-Key': PROXY_KEY } }, 8000)
        .then(function (res) { return res.json(); })
        .then(function (j) {
            if (!j || !j.ok || !Array.isArray(j.sources)) throw new Error('réponse invalide');
            $sourcesList.innerHTML = j.sources.map(function (s) {
                return '<div class="adm-key-row" data-name="' + esc(s.name) + '">' +
                    '<label style="display:flex;align-items:center;gap:10px;cursor:pointer">' +
                        '<input type="checkbox" class="adm-source-toggle" style="width:18px;height:18px;accent-color:#16a34a"' + (s.enabled ? ' checked' : '') + ' />' +
                        '<span>' + esc(s.label) + '</span>' +
                    '</label>' +
                    '<div class="adm-key-status">' + (s.enabled
                        ? '<span class="adm-key-set">&#10003; API nationale active</span>'
                        : '<span class="adm-key-unset">iudo (défaut)</span>') + '</div>' +
                '</div>';
            }).join('');
            $sourcesList.querySelectorAll('.adm-key-row').forEach(function (row) {
                row.querySelector('.adm-source-toggle').addEventListener('change', function (ev) {
                    saveSource(row, !!ev.target.checked);
                });
            });
        })
        .catch(function () {
            $sourcesList.innerHTML = '<p class="text-muted text-sm">Impossible de charger les sources (serveur injoignable).</p>';
        });
}

function saveSource(row, enabled) {
    var name = row.getAttribute('data-name');
    var status = row.querySelector('.adm-key-status');
    timedFetch(PROXY + '?action=sources_set', {
        method: 'POST',
        headers: { 'X-Admin-Key': PROXY_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: name, enabled: enabled }),
    }, 8000)
        .then(function (res) { return res.json(); })
        .then(function (j) {
            if (!j || !j.ok) throw new Error('échec');
            nationalSources[name] = !!j.enabled;
            status.innerHTML = j.enabled
                ? '<span class="adm-key-set">&#10003; API nationale active</span>'
                : '<span class="adm-key-unset">iudo (défaut)</span>';
        })
        .catch(function () {
            status.innerHTML = '<span class="adm-key-unset">Erreur d\'enregistrement — réessayer</span>';
            renderSources();
        });
}

if ($sourcesList) renderSources();

});