Your IP : 216.73.217.68


Current Path : /home/atelierpru/www/airvalue/adm-api/
Upload File :
Current File : /home/atelierpru/www/airvalue/adm-api/proxy.php

<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');

register_shutdown_function(function () {
    $err = error_get_last();
    if ($err && in_array($err['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR), true)) {
        if (!headers_sent()) {
            http_response_code(500);
            header('Content-Type: application/json; charset=utf-8');
        }
        echo json_encode(array(
            'ok'    => false,
            'error' => 'PHP fatal: ' . $err['message'],
            'file'  => basename($err['file']),
            'line'  => $err['line'],
        ));
    }
});

define('IUDO_EMAIL',        'elibensadoun@gmail.com');
define('IUDO_PASSWORD',     'Fefef430430@');
define('IUDO_API_BASE',     'https://prod-plu-back.iudo.co');
define('ADMIN_KEY',         'Fefef430430@');
define('BUILD_ID',          '20260801b');
define('CACHE_TTL',         7 * 86400);
define('CACHE_PREFIX',      'iudo_cache_');
define('HISTORY_MAX',       50);

// Stockage PERSISTANT (cache + historique), indépendant du build front et du
// répertoire temporaire (qui peut être purgé). On tente d'abord un dossier
// « data » à côté de proxy.php ; si non inscriptible, repli sur le tmp système.
function storeDir()
{
    static $dir = null;
    if ($dir !== null) return $dir;
    $candidate = __DIR__ . '/data';
    if (!is_dir($candidate)) {
        @mkdir($candidate, 0775, true);
    }
    if (is_dir($candidate) && is_writable($candidate)) {
        // Empêche le listing / l'accès HTTP direct au dossier de données.
        $ht = $candidate . '/.htaccess';
        if (!is_file($ht)) {
            @file_put_contents($ht, "Require all denied\nDeny from all\n");
        }
        $dir = $candidate;
    } else {
        $dir = sys_get_temp_dir();
    }
    return $dir;
}

function cacheDir()
{
    $dir = storeDir() . '/cache';
    if (!is_dir($dir)) @mkdir($dir, 0775, true);
    return is_dir($dir) && is_writable($dir) ? $dir : storeDir();
}

function historyFile()
{
    return storeDir() . '/history.json';
}

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');

function readHistory()
{
    $file = historyFile();
    if (!is_file($file)) return array();
    $raw = @file_get_contents($file);
    if ($raw === false || $raw === '') return array();
    $data = json_decode($raw, true);
    return is_array($data) ? $data : array();
}

function writeHistory($entries)
{
    $file = historyFile();
    $fp = @fopen($file, 'c+');
    if (!$fp) return false;
    $ok = false;
    if (flock($fp, LOCK_EX)) {
        ftruncate($fp, 0);
        rewind($fp);
        fwrite($fp, json_encode(array_values($entries), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        fflush($fp);
        flock($fp, LOCK_UN);
        $ok = true;
    }
    fclose($fp);
    return $ok;
}

function respond($payload, $httpCode = 200)
{
    http_response_code($httpCode);
    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function fail($action, $error, $httpCode = 400, $extra = array())
{
    respond(array_merge(array(
        'ok'       => false,
        'status'   => $httpCode,
        'action'   => $action,
        'error'    => $error,
        'build'    => BUILD_ID,
    ), $extra), $httpCode);
}

// ---- Clés API tierces (stockage persistant, jamais renvoyées en clair) ------
// Allowlist des clés gérables depuis l'admin. name => libellé affiché.
function knownApiKeys()
{
    return array(
        'georisques' => 'Géorisques — jeton API Cerbère',
        'paris_data' => 'Paris Data (opendata.paris.fr) — clé API',
        'cerema'     => 'Cerema Datafoncier — clé / accès',
        'ign'        => 'IGN Géoplateforme — clé (si requise)',
        'dvf'        => 'DVF+ / API données foncières — clé (si requise)',
    );
}

function apiKeysFile()
{
    return storeDir() . '/api_keys.json';
}

function readApiKeys()
{
    $file = apiKeysFile();
    if (!is_file($file)) return array();
    $raw = @file_get_contents($file);
    if ($raw === false || $raw === '') return array();
    $data = json_decode($raw, true);
    return is_array($data) ? $data : array();
}

function writeApiKeys($keys)
{
    $file = apiKeysFile();
    $fp = @fopen($file, 'c+');
    if (!$fp) return false;
    $ok = false;
    if (flock($fp, LOCK_EX)) {
        ftruncate($fp, 0);
        rewind($fp);
        fwrite($fp, json_encode($keys, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        fflush($fp);
        flock($fp, LOCK_UN);
        $ok = true;
    }
    fclose($fp);
    return $ok;
}

// Indice masqué d'une valeur : ne révèle que les 4 derniers caractères.
function maskKey($value)
{
    $len = strlen($value);
    if ($len === 0) return '';
    if ($len <= 4) return str_repeat("\xE2\x80\xA2", $len);
    return str_repeat("\xE2\x80\xA2", 8) . substr($value, -4);
}

// Accès en clair pour l'usage interne (appels aux API tierces à venir).
function apiKeyValue($name)
{
    $keys = readApiKeys();
    return isset($keys[$name]) ? (string) $keys[$name] : '';
}

$action   = isset($_GET['action']) ? (string) $_GET['action'] : '';
$adminKey = isset($_SERVER['HTTP_X_ADMIN_KEY']) ? (string) $_SERVER['HTTP_X_ADMIN_KEY'] : '';

// Les actions « mvp_* » (socle national : BAN, cadastre, GPU, BD TOPO — données
// publiques) sont accessibles sans clé admin : la landing les appelle
// directement. Tout le reste (iudo, clés, historique) reste protégé.
$isPublicAction = strpos($action, 'mvp_') === 0;

if (!$isPublicAction && !hash_equals(ADMIN_KEY, $adminKey)) {
    fail($action, 'Forbidden', 403);
}

if ($action === 'ping') {
    $cacheFiles = glob(cacheDir() . '/' . CACHE_PREFIX . '*.json');
    respond(array(
        'ok'          => true,
        'action'      => 'ping',
        'build'       => BUILD_ID,
        'php'         => PHP_VERSION,
        'curl'        => function_exists('curl_init'),
        'store'       => storeDir(),
        'store_w'     => is_writable(storeDir()),
        'persistent'  => storeDir() !== sys_get_temp_dir(),
        'cache'       => is_array($cacheFiles) ? count($cacheFiles) : 0,
        'history'     => count(readHistory()),
        'keys_set'    => count(readApiKeys()),
        'session'     => sessionInfo(),
        'time'        => date('c'),
    ));
}

// Session iudo persistante : le cookie jar vit dans le stockage persistant
// (adm-api/data/) et non plus dans le tmp système purgeable — on ne se
// reconnecte que si le cookie Authentication est absent/expiré (cf.
// ensureSession), le refresh périodique GitHub Actions maintient la session.
function cookieJarPath()
{
    static $path = null;
    if ($path !== null) return $path;
    $path = storeDir() . '/iudo_cookies.txt';
    // Migration one-shot depuis l'ancien emplacement tmp pour ne pas perdre
    // la session en cours lors du déploiement.
    $legacy = sys_get_temp_dir() . '/iudo_cookies_shared.txt';
    if (!is_file($path) && is_file($legacy)) {
        @copy($legacy, $path);
    }
    return $path;
}
$cookieJar = cookieJarPath();

// État de la session iudo d'après le cookie jar : active + date d'expiration
// du cookie Authentication.
function sessionInfo()
{
    $info = array('active' => false, 'expires' => null);
    $jar = cookieJarPath();
    if (!is_file($jar)) return $info;
    $lines = @file($jar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if (!$lines) return $info;
    $now = time();
    foreach ($lines as $line) {
        if ($line !== '' && $line[0] === '#') {
            if (strpos($line, '#HttpOnly_') !== 0) continue;
            $line = substr($line, strlen('#HttpOnly_'));
        }
        $parts = explode("\t", $line);
        if (count($parts) >= 7 && $parts[5] === 'Authentication') {
            $expiry = (int) $parts[4];
            if ($expiry === 0 || $expiry > $now) {
                $info['active'] = true;
                $info['expires'] = $expiry === 0 ? null : date('c', $expiry);
                return $info;
            }
            $info['expires'] = $expiry ? date('c', $expiry) : null;
        }
    }
    return $info;
}

$iudoHeaders = array(
    'Accept: application/json, text/plain, */*',
    'Content-Type: application/json',
    'Origin: https://app.iudo.co',
    'Referer: https://app.iudo.co/',
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
);

function iudoCurl($method, $path, $body = null)
{
    global $cookieJar, $iudoHeaders;

    $ch = curl_init();
    curl_setopt_array($ch, array(
        CURLOPT_URL            => IUDO_API_BASE . $path,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER         => false,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_HTTPHEADER     => $iudoHeaders,
        CURLOPT_COOKIEJAR      => $cookieJar,
        CURLOPT_COOKIEFILE     => $cookieJar,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_ENCODING       => '',
    ));

    if ($method === 'POST') {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body !== null ? $body : '');
    }

    $responseBody = curl_exec($ch);
    $status       = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlError    = curl_errno($ch) ? curl_error($ch) : null;
    curl_close($ch);

    return array(
        'status' => $status,
        'body'   => $responseBody === false ? '' : (string) $responseBody,
        'error'  => $curlError,
    );
}

function hasValidAuthCookie()
{
    $info = sessionInfo();
    return $info['active'];
}

function iudoLogin()
{
    $payload = json_encode(array(
        'email'    => IUDO_EMAIL,
        'password' => IUDO_PASSWORD,
    ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    return iudoCurl('POST', '/auth/log-in', $payload);
}

function iudoRefresh()
{
    return iudoCurl('GET', '/auth/refresh');
}

function ensureSession()
{
    if (hasValidAuthCookie()) {
        return;
    }
    iudoLogin();
}

function iudoAuthedCall($method, $path, $body = null)
{
    ensureSession();

    $res = iudoCurl($method, $path, $body);

    if ($res['status'] === 401) {
        $refresh = iudoRefresh();
        if ($refresh['status'] >= 200 && $refresh['status'] < 300) {
            $res = iudoCurl($method, $path, $body);
        }
        if ($res['status'] === 401) {
            iudoLogin();
            $res = iudoCurl($method, $path, $body);
        }
    }

    return $res;
}

function reqParam($name, $action)
{
    if (!isset($_GET[$name]) || $_GET[$name] === '') {
        fail($action, "Parametre manquant : $name", 400);
    }
    return (string) $_GET[$name];
}

function rawPostBody()
{
    $body = file_get_contents('php://input');
    return $body === false ? '' : $body;
}

function emitIudoResponse($action, $res, $startedAt, $cacheFile = null)
{
    $durationMs = (int) round((microtime(true) - $startedAt) * 1000);

    if ($res['error'] !== null && $res['status'] === 0) {
        respond(array(
            'ok'          => false,
            'status'      => 0,
            'action'      => $action,
            'duration_ms' => $durationMs,
            'error'       => 'Erreur reseau curl : ' . $res['error'],
            'raw'         => '',
            'build'       => BUILD_ID,
        ), 502);
    }

    $status  = $res['status'];
    $rawBody = $res['body'];

    $data = null;
    if ($rawBody !== '') {
        $decoded = json_decode($rawBody, true);
        $data    = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
    }

    $ok = $status >= 200 && $status < 300;

    if ($ok) {
        $payload = array(
            'ok'          => true,
            'status'      => $status,
            'action'      => $action,
            'duration_ms' => $durationMs,
            'data'        => $data,
            'raw'         => ($data === null && $rawBody !== '') ? $rawBody : null,
            'cached'      => false,
            'build'       => BUILD_ID,
        );
        // Règle Step 10 : ne jamais mettre en cache une réponse métier vide
        // (un 200 avec data null n'est pas un résultat terminé).
        if ($cacheFile !== null && $data !== null) {
            @file_put_contents($cacheFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
        }
        respond($payload, 200);
    }

    $errorMsg = 'HTTP ' . $status;
    if (is_array($data) && isset($data['message'])) {
        $errorMsg .= ' : ' . (is_array($data['message'])
            ? implode(', ', $data['message'])
            : (string) $data['message']);
    } elseif (is_array($data) && isset($data['error_code'])) {
        $errorMsg .= ' : ' . $data['error_code'];
    }

    respond(array(
        'ok'          => false,
        'status'      => $status,
        'action'      => $action,
        'duration_ms' => $durationMs,
        'error'       => $errorMsg,
        'data'        => $data,
        'raw'         => $rawBody,
        'build'       => BUILD_ID,
    ), 200);
}

if ($action === '') {
    fail('', 'Parametre action manquant', 400);
}

// Retourne array(method, path, body, cacheable) ou null si action inconnue.
// cacheable = false pour les actions qui écrivent côté iudo (tracking, quota).
function iudoRoute($action)
{
    switch ($action) {
        case 'parcels':
            return array('GET', '/parcels?lat=' . urlencode(reqParam('lat', $action)) . '&lon=' . urlencode(reqParam('lon', $action)), null, true);
        case 'parcel_detail':
            return array('GET', '/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
        case 'parcel_coords':
            return array('GET', '/parcels/' . rawurlencode(reqParam('parcelId', $action)) . '/coordinates', null, true);
        case 'parcel_units':
            return array('GET', '/parcels/' . rawurlencode(reqParam('parcelId', $action)) . '/units?no_adu=', null, true);
        case 'urban_zones':
            return array('GET', '/urban_zones/zones/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'airtable_plu':
            $zoneId = reqParam('zoneId', $action);
            $body = rawPostBody();
            if (trim($body) === '') {
                $body = json_encode(array(
                    'urbanZoneParams' => array('id' => $zoneId, 'type' => 'PLU'),
                ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            }
            return array('POST', '/airtable/plu/' . rawurlencode($zoneId), $body, true);
        case 'building_rights':
            return array('GET', '/airtable/plu/' . rawurlencode(reqParam('zoneId', $action)) . '/building-rights', null, true);
        case 'town_psc':
            return array('GET', '/town/psc/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'docurba':
            return array('GET', '/docurba/urban-zone-type/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'gpu_plu_info':
            return array('GET', '/geoportail-urbanisme/plu-info-insee/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'psmv':
            return array('GET', '/psmv/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
        case 'districts':
            return array('GET', '/districts/insee/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'dvf_stats':
            return array('GET', '/dvf/sales-stats/' . rawurlencode(reqParam('insee', $action)) . '?startYear=2020&endYear=2025', null, true);
        case 'dvf_series':
            return array('GET', '/dvf/sales-stats-series/' . rawurlencode(reqParam('insee', $action)) . '?startYear=2020&endYear=2025', null, true);
        case 'dvf_parcels':
            return array('GET', '/dvf/sales/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
        case 'loyers':
            return array('GET', '/loyers/2025/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'adu_totals':
            return array('GET', '/adu/totals/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'adu_parcels':
            return array('GET', '/adu/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
        case 'adu_insee':
            return array('GET', '/adu/' . rawurlencode(reqParam('insee', $action)), null, true);
        case 'prevention_plans':
            return array('GET', '/sup-surf/prevention-plans/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
        case 'georisques':
            return array('GET', '/georisques/address-risks?lon=' . urlencode(reqParam('lon', $action))
                . '&lat=' . urlencode(reqParam('lat', $action))
                . '&codeInsee=' . urlencode(reqParam('insee', $action)), null, true);
        case 'analytics':
            $body = rawPostBody();
            if (trim($body) === '') {
                $body = '{}';
            }
            return array('POST', '/analytics/add', $body, false);
        case 'search_count':
            return array('POST', '/user/search-count', '', false);
    }
    return null;
}

$startedAt = microtime(true);

if ($action === 'login') {
    $res = iudoLogin();
    emitIudoResponse($action, $res, $startedAt);
}

if ($action === 'cache_clear') {
    $files = glob(cacheDir() . '/' . CACHE_PREFIX . '*.json');
    $deleted = 0;
    if (is_array($files)) {
        foreach ($files as $f) {
            if (@unlink($f)) $deleted++;
        }
    }
    respond(array('ok' => true, 'action' => 'cache_clear', 'deleted' => $deleted, 'build' => BUILD_ID));
}

// ---- Historique serveur (partagé, persistant) ------------------------------
// Liste des dernières adresses analysées, indépendante du navigateur et du
// build front. Chaque entrée conserve la feature géocodée (BAN) pour permettre
// le rejeu côté client.

if ($action === 'history') {
    respond(array('ok' => true, 'action' => 'history', 'entries' => readHistory(), 'build' => BUILD_ID));
}

if ($action === 'history_add') {
    $body = json_decode(rawPostBody(), true);
    $banId = (is_array($body) && isset($body['banId'])) ? (string) $body['banId'] : '';
    if ($banId === '') {
        fail($action, 'banId manquant', 400);
    }
    $entry = array(
        'banId'   => $banId,
        'label'   => isset($body['label']) ? (string) $body['label'] : '',
        'insee'   => isset($body['insee']) ? (string) $body['insee'] : '',
        'feature' => isset($body['feature']) ? $body['feature'] : null,
        // Chaîne (pas de cast int) : évite l'overflow des entiers 32 bits sur
        // certains hébergements (le timestamp ms ~1.78e12 dépasse INT32_MAX,
        // ce qui produisait des dates absurdes type 1904).
        'savedAt' => sprintf('%.0f', microtime(true) * 1000),
    );
    $entries = readHistory();
    $filtered = array();
    foreach ($entries as $e) {
        if (!is_array($e) || !isset($e['banId']) || $e['banId'] !== $banId) {
            $filtered[] = $e;
        }
    }
    array_unshift($filtered, $entry);
    if (count($filtered) > HISTORY_MAX) {
        $filtered = array_slice($filtered, 0, HISTORY_MAX);
    }
    $ok = writeHistory($filtered);
    respond(array('ok' => $ok, 'action' => 'history_add', 'count' => count($filtered), 'build' => BUILD_ID));
}

if ($action === 'history_clear') {
    $ok = writeHistory(array());
    respond(array('ok' => $ok, 'action' => 'history_clear', 'build' => BUILD_ID));
}

// ---- Clés API : lecture (masquée) et écriture --------------------------------

if ($action === 'keys_get') {
    $stored = readApiKeys();
    $out = array();
    foreach (knownApiKeys() as $name => $label) {
        $val = isset($stored[$name]) ? (string) $stored[$name] : '';
        $out[] = array(
            'name'  => $name,
            'label' => $label,
            'set'   => $val !== '',
            'hint'  => $val !== '' ? maskKey($val) : '',
        );
    }
    respond(array('ok' => true, 'action' => 'keys_get', 'keys' => $out, 'build' => BUILD_ID));
}

if ($action === 'keys_set') {
    $body = json_decode(rawPostBody(), true);
    $name = (is_array($body) && isset($body['name'])) ? (string) $body['name'] : '';
    if (!array_key_exists($name, knownApiKeys())) {
        fail($action, 'Clé inconnue', 400);
    }
    $value = (is_array($body) && isset($body['value'])) ? trim((string) $body['value']) : '';
    $keys = readApiKeys();
    if ($value === '') {
        unset($keys[$name]);
    } else {
        $keys[$name] = $value;
    }
    $ok = writeApiKeys($keys);
    respond(array(
        'ok'   => $ok,
        'action' => 'keys_set',
        'name' => $name,
        'set'  => $value !== '',
        'hint' => $value !== '' ? maskKey($value) : '',
        'build' => BUILD_ID,
    ));
}

// ---- MVP V1 : socle national (BAN, cadastre IGN, GPU, BD TOPO) --------------
// Actions publiques appelées par l'onglet MVP de /adm ET par la landing : le
// navigateur ne parle qu'à airvalue.fr, les appels externes se font ici.
// Docs métier : STEP 1 (S1.1-S1.8), STEP 7, STEP 11, P2, P3.

function publicCurl($url)
{
    $ch = curl_init();
    curl_setopt_array($ch, array(
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_TIMEOUT        => 25,
        CURLOPT_CONNECTTIMEOUT => 8,
        CURLOPT_HTTPHEADER     => array('Accept: application/json'),
        CURLOPT_USERAGENT      => 'AirValue/1.0 (analyse fonciere; contact@airvalue.fr)',
        CURLOPT_ENCODING       => '',
    ));
    $body  = curl_exec($ch);
    $code  = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_errno($ch) ? curl_error($ch) : null;
    curl_close($ch);
    return array('status' => $code, 'body' => $body === false ? '' : (string) $body, 'error' => $error);
}

// Géométrie Point GeoJSON encodée pour apicarto.
function gpuPointGeom($lon, $lat)
{
    return rawurlencode('{"type":"Point","coordinates":[' . $lon . ',' . $lat . ']}');
}

// BBox WFS Géoplateforme autour d'un point (CRS:84 : lon,lat).
function wfsBboxUrl($typename, $lon, $lat, $dLon, $dLat, $count)
{
    return 'https://data.geopf.fr/wfs/ows?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature'
        . '&TYPENAME=' . rawurlencode($typename) . '&TYPENAMES=' . rawurlencode($typename)
        . '&OUTPUTFORMAT=application/json&COUNT=' . (int) $count
        . '&SRSNAME=CRS:84&BBOX=' . ($lon - $dLon) . ',' . ($lat - $dLat) . ',' . ($lon + $dLon) . ',' . ($lat + $dLat) . ',CRS:84';
}

// Retourne l'URL externe pour une action mvp simple, ou null.
function mvpRoute($action)
{
    switch ($action) {
        case 'mvp_geocode':
            // S1.1 — géocodage BAN (adresse libre -> position + score + INSEE).
            $q = reqParam('q', $action);
            $limit = isset($_GET['limit']) ? max(1, min(10, (int) $_GET['limit'])) : 5;
            return 'https://api-adresse.data.gouv.fr/search/?q=' . rawurlencode($q) . '&limit=' . $limit;
        case 'mvp_batiments':
            // S1.3 / P3 — bâtiments BD TOPO autour du point (géométrie + hauteur
            // + nb niveaux par bâtiment ; sert aussi aux mitoyens et à la minimap).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return wfsBboxUrl('BDTOPO_V3:batiment', $lon, $lat, 0.0026, 0.0017, 400);
        case 'mvp_troncon':
            // P2 — tronçons de route proches (largeur_de_chaussee : prudente,
            // measurement_type ROADWAY_WIDTH, jamais présentée comme réglementaire).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return wfsBboxUrl('BDTOPO_V3:troncon_de_route', $lon, $lat, 0.0009, 0.0006, 50);
        case 'mvp_zone_urba':
            // S1.7 — zone(s) PLU intersectant le point (GPU).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return 'https://apicarto.ign.fr/api/gpu/zone-urba?geom=' . gpuPointGeom($lon, $lat);
        case 'mvp_document':
            // S1.4 / STEP 11 — document d'urbanisme applicable (GPU).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return 'https://apicarto.ign.fr/api/gpu/document?geom=' . gpuPointGeom($lon, $lat);
        case 'mvp_servitudes':
            // S1.8 — servitudes d'utilité publique (assiettes surfaciques).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return 'https://apicarto.ign.fr/api/gpu/assiette-sup-s?geom=' . gpuPointGeom($lon, $lat);
        case 'mvp_pvp':
            // Largeur de voie — Plan de Voirie de Paris (opendata.paris.fr,
            // Paris uniquement). layer : emprises | chaussees | trottoirs.
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            $pvp = array(
                'emprises'  => 'denominations-emprises-voies-actuelles',
                'chaussees' => 'plan-de-voirie-chaussees',
                'trottoirs' => 'plan-de-voirie-trottoirs-emprises',
            );
            $layer = isset($_GET['layer']) ? (string) $_GET['layer'] : 'emprises';
            if (!isset($pvp[$layer])) return null;
            return 'https://opendata.paris.fr/api/explore/v2.1/catalog/datasets/' . $pvp[$layer]
                . '/records?where=' . rawurlencode("within_distance(geo_shape, geom'POINT(" . $lon . ' ' . $lat . ")', 60m)")
                . '&limit=50';
        case 'mvp_parcelles_zone':
            // Largeur de voie — parcelles cadastrales de la zone (~±55 m) pour
            // mesurer le vide entre parcelles opposées (emprise publique).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return wfsBboxUrl('CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle', $lon, $lat, 0.0005, 0.0005, 100);
        case 'mvp_apur':
            // Largeur de voie — tronçons APUR enrichis (data.smartidf.services,
            // Paris/IDF : largeurs trottoirs et attributs de voie).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return 'https://data.smartidf.services/api/explore/v2.1/catalog/datasets/troncon-voie-paris'
                . '/records?where=' . rawurlencode("within_distance(geo_shape, geom'POINT(" . $lon . ' ' . $lat . ")', 60m)")
                . '&limit=20';
        case 'mvp_alti':
            // Altitude NGF du sol (service altimétrie IGN) — sert à caler les
            // volumes 3D sur le sol photoréaliste Google (hauteur ellipsoïdale
            // = NGF + ondulation du géoïde ~44,6 m en France métropolitaine).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return 'https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json'
                . '?lon=' . $lon . '&lat=' . $lat . '&resource=ign_rge_alti_wld&zonly=false&measures=false';
        case 'mvp_osm_width':
            // Largeur de voie — tag width OSM (Overpass, fallback partiel).
            $lon = (float) reqParam('lon', $action);
            $lat = (float) reqParam('lat', $action);
            return 'https://overpass-api.de/api/interpreter?data=' . rawurlencode(
                '[out:json][timeout:10];way(around:40,' . $lat . ',' . $lon . ')[highway]["width"];out tags geom;'
            );
    }
    return null;
}

if ($isPublicAction) {
    $noCache = isset($_GET['nocache']) && $_GET['nocache'] === '1';

    if ($action === 'mvp_prescriptions') {
        // S1.8 — prescriptions surfaciques + linéaires + ponctuelles (GPU),
        // fusionnées en une seule FeatureCollection taguée par couche.
        $lon = (float) reqParam('lon', $action);
        $lat = (float) reqParam('lat', $action);
        $geom = gpuPointGeom($lon, $lat);
        $cacheFile = cacheDir() . '/' . CACHE_PREFIX . md5($action . '|' . $lon . '|' . $lat) . '.json';
        if (!$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
            $payload = json_decode(file_get_contents($cacheFile), true);
            if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
                $payload['cached'] = true;
                $payload['cached_at'] = date('c', filemtime($cacheFile));
                respond($payload, 200);
            }
        }
        $layers = array(
            'SURF' => 'https://apicarto.ign.fr/api/gpu/prescription-surf?geom=' . $geom,
            'LIN'  => 'https://apicarto.ign.fr/api/gpu/prescription-lin?geom=' . $geom,
            'PCT'  => 'https://apicarto.ign.fr/api/gpu/prescription-pct?geom=' . $geom,
        );
        $features = array();
        $layerStatus = array();
        $anyOk = false;
        foreach ($layers as $tag => $url) {
            $res = publicCurl($url);
            $ok = $res['error'] === null && $res['status'] >= 200 && $res['status'] < 300;
            $layerStatus[$tag] = $ok ? 'OK' : 'ERROR';
            if (!$ok) continue;
            $fc = json_decode($res['body'], true);
            if (is_array($fc) && isset($fc['features']) && is_array($fc['features'])) {
                $anyOk = true;
                foreach ($fc['features'] as $f) {
                    if (!is_array($f)) continue;
                    if (!isset($f['properties']) || !is_array($f['properties'])) $f['properties'] = array();
                    $f['properties']['gpu_layer'] = $tag;
                    $features[] = $f;
                }
            }
        }
        if (!$anyOk) {
            fail($action, 'GPU prescriptions indisponibles', 502, array('layers' => $layerStatus));
        }
        $payload = array(
            'ok'     => true,
            'status' => 200,
            'action' => $action,
            'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
            'data'   => array('type' => 'FeatureCollection', 'features' => $features, 'layers' => $layerStatus),
            'cached' => false,
            'build'  => BUILD_ID,
        );
        @file_put_contents($cacheFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
        respond($payload, 200);
    }

    if ($action === 'mvp_parcelle') {
        $lon = (float) reqParam('lon', $action);
        $lat = (float) reqParam('lat', $action);
        $cacheFile = cacheDir() . '/' . CACHE_PREFIX . md5('v2|' . $action . '|' . $lon . '|' . $lat) . '.json';
        if (!$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
            $payload = json_decode(file_get_contents($cacheFile), true);
            if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
                $payload['cached'] = true;
                $payload['cached_at'] = date('c', filemtime($cacheFile));
                respond($payload, 200);
            }
        }
        $sourceDetail = array(
            'apicarto_status' => null, 'apicarto_error' => null, 'apicarto_features' => 0,
            'wfs_status' => null, 'wfs_error' => null, 'wfs_features' => 0,
        );
        // Primary: apicarto IGN (PCI vecteur) — server-side intersection, exact match.
        $geom = gpuPointGeom($lon, $lat);
        $url1 = 'https://apicarto.ign.fr/api/cadastre/parcelle?geom=' . $geom;
        $res1 = publicCurl($url1);
        $source = 'apicarto';
        $features = array();
        $sourceDetail['apicarto_status'] = $res1['status'];
        $sourceDetail['apicarto_error'] = $res1['error'];
        if ($res1['error'] === null && $res1['status'] >= 200 && $res1['status'] < 300) {
            $fc = json_decode($res1['body'], true);
            if (is_array($fc) && isset($fc['features']) && is_array($fc['features'])) {
                $features = $fc['features'];
            }
        } elseif ($res1['error'] !== null || $res1['status'] === 429 || $res1['status'] >= 500) {
            // Retry once on curl error, 429, or 5xx
            usleep(400000);
            $res1b = publicCurl($url1);
            $sourceDetail['apicarto_status'] = $res1b['status'];
            $sourceDetail['apicarto_error'] = $res1b['error'];
            if ($res1b['error'] === null && $res1b['status'] >= 200 && $res1b['status'] < 300) {
                $fc = json_decode($res1b['body'], true);
                if (is_array($fc) && isset($fc['features']) && is_array($fc['features'])) {
                    $features = $fc['features'];
                }
            }
        }
        $sourceDetail['apicarto_features'] = count($features);
        // Fallback: WFS Géoplateforme parcellaire express — tiny bbox for deterministic result.
        if (empty($features)) {
            $wfsType = 'CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle';
            // Pass 1: ±0.00005° (~±5 m) — the containing parcel always intersects this box.
            $url2 = wfsBboxUrl($wfsType, $lon, $lat, 0.00005, 0.00005, 20);
            $res2 = publicCurl($url2);
            $source = 'wfs_pe';
            $sourceDetail['wfs_status'] = $res2['status'];
            $sourceDetail['wfs_error'] = $res2['error'];
            if ($res2['error'] === null && $res2['status'] >= 200 && $res2['status'] < 300) {
                $fc2 = json_decode($res2['body'], true);
                if (is_array($fc2) && isset($fc2['features']) && is_array($fc2['features'])) {
                    $features = $fc2['features'];
                }
            }
            // Pass 2 (if 0 features): widen to ±0.0003° (~±30 m) with COUNT=50.
            if (empty($features)) {
                $url2b = wfsBboxUrl($wfsType, $lon, $lat, 0.0003, 0.0003, 50);
                $res2b = publicCurl($url2b);
                $sourceDetail['wfs_status'] = $res2b['status'];
                $sourceDetail['wfs_error'] = $res2b['error'];
                if ($res2b['error'] === null && $res2b['status'] >= 200 && $res2b['status'] < 300) {
                    $fc2b = json_decode($res2b['body'], true);
                    if (is_array($fc2b) && isset($fc2b['features']) && is_array($fc2b['features'])) {
                        $features = $fc2b['features'];
                    }
                }
            }
            // Re-map WFS PE properties to match apicarto shape.
            foreach ($features as &$f) {
                if (!isset($f['properties']) || !is_array($f['properties'])) continue;
                $p = $f['properties'];
                if (!isset($p['idu']) && isset($p['id'])) {
                    $f['properties']['idu'] = $p['id'];
                }
            }
            unset($f);
            $sourceDetail['wfs_features'] = count($features);
        }
        if (empty($features)) {
            fail($action, 'Aucune parcelle cadastrale trouvee', 404);
        }
        $payload = array(
            'ok'     => true,
            'status' => 200,
            'action' => $action,
            'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
            'data'   => array(
                'type' => 'FeatureCollection', 'features' => $features,
                'source' => $source, 'source_detail' => $sourceDetail,
            ),
            'cached' => false,
            'build'  => BUILD_ID,
        );
        @file_put_contents($cacheFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
        respond($payload, 200);
    }

    $url = mvpRoute($action);
    if ($url === null) {
        fail($action, "Action inconnue : $action", 404);
    }
    $cacheFile = cacheDir() . '/' . CACHE_PREFIX . md5($action . '|' . $url) . '.json';
    if (!$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
        $payload = json_decode(file_get_contents($cacheFile), true);
        if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
            $payload['cached'] = true;
            $payload['cached_at'] = date('c', filemtime($cacheFile));
            respond($payload, 200);
        }
    }
    $res = publicCurl($url);
    emitIudoResponse($action, $res, $startedAt, $cacheFile);
}

// ---- Sources de données : switches iudo -> APIs nationales ------------------
// Config partagée (data/sources.json) pilotant, dans le flow POC v0, le
// remplacement des appels iudo par nos actions mvp_* quand c'est possible.
// Défaut : tout OFF = iudo. Clés connues : parcelle_cadastre, zone_plu,
// servitudes (APIs nationales gratuites, sans clé).

function sourcesFile()
{
    return storeDir() . '/sources.json';
}

function knownSources()
{
    return array(
        'parcelle_cadastre' => 'Parcelle cadastrale — apicarto IGN (gratuit, sans clé)',
        'zone_plu'          => 'Zone PLU — Géoportail de l\'urbanisme (gratuit, sans clé)',
        'servitudes'        => 'Servitudes / PSMV — GPU assiettes SUP (gratuit, sans clé)',
    );
}

function readSources()
{
    $raw = @file_get_contents(sourcesFile());
    if ($raw === false || $raw === '') return array();
    $data = json_decode($raw, true);
    return is_array($data) ? $data : array();
}

if ($action === 'sources_get') {
    $stored = readSources();
    $out = array();
    foreach (knownSources() as $name => $label) {
        $out[] = array(
            'name'    => $name,
            'label'   => $label,
            'enabled' => !empty($stored[$name]),
        );
    }
    respond(array('ok' => true, 'action' => 'sources_get', 'sources' => $out, 'build' => BUILD_ID));
}

if ($action === 'sources_set') {
    $body = json_decode(rawPostBody(), true);
    $name = (is_array($body) && isset($body['name'])) ? (string) $body['name'] : '';
    if (!array_key_exists($name, knownSources())) {
        fail($action, 'Source inconnue', 400);
    }
    $enabled = is_array($body) && !empty($body['enabled']);
    $stored = readSources();
    $stored[$name] = $enabled;
    $ok = @file_put_contents(sourcesFile(), json_encode($stored, JSON_UNESCAPED_UNICODE), LOCK_EX) !== false;
    respond(array('ok' => $ok, 'action' => 'sources_set', 'name' => $name, 'enabled' => $enabled, 'build' => BUILD_ID));
}

$route = iudoRoute($action);
if ($route === null) {
    fail($action, "Action inconnue : $action", 404);
}

$method    = $route[0];
$path      = $route[1];
$body      = $route[2];
$cacheable = $route[3];

$cacheFile = $cacheable
    ? cacheDir() . '/' . CACHE_PREFIX . md5($path . '|' . (string) $body) . '.json'
    : null;
$noCache = isset($_GET['nocache']) && $_GET['nocache'] === '1';

if ($cacheFile !== null && !$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
    $payload = json_decode(file_get_contents($cacheFile), true);
    if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
        $payload['cached']      = true;
        $payload['cached_at']   = date('c', filemtime($cacheFile));
        $payload['duration_ms'] = (int) round((microtime(true) - $startedAt) * 1000);
        respond($payload, 200);
    }
}

$res = iudoAuthedCall($method, $path, $body);
emitIudoResponse($action, $res, $startedAt, $cacheFile);