<?php

/**
 * FinDeVisite.php — Version complète (SANDBOX) + Token (refresh_token) + UI de test
 * API:
 *   POST  FinDeVisite.php?action=create_all   (body JSON)
 */

// ---------------------------------------------------------------------
// DEBUG (évite les erreurs 500 silencieuses)
// ---------------------------------------------------------------------
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);


if (!function_exists('curl_init')) {
    die("❌ PHP cURL n'est pas activé");
}

header("Content-Type: text/html; charset=utf-8");

// ---------------------------------------------------------
// LOAD ENV
// ---------------------------------------------------------
require __DIR__ . '/vendor/autoload.php';

use Dotenv\Dotenv;

$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();

$clientId     = $_ENV['SF_CLIENT_ID'];
$clientSecret = $_ENV['SF_CLIENT_SECRET'];
$tokenUrl     = $_ENV['SF_TOKEN_URL'];
$refreshToken = $_ENV['SF_REFRESH_TOKEN'];
$tokenFile    = __DIR__ . '/token.json';
$logFile      = __DIR__ . '/fdv.log';
$apiVersion   = 'v62.0';
$auditDir     = __DIR__ . '/fdv_audit';
if (!is_dir($auditDir)) @mkdir($auditDir, 0775, true);


// =====================================================================
// AUDIT JSON (INPUT/OUTPUT) - sans casser l'API si non inscriptible
// =====================================================================
function audit_json_request($input, $output, int $httpCode = 200, array $meta = []): void
{
    global $auditDir;

    if (!is_dir($auditDir) || !is_writable($auditDir)) return;

    // id unique par requête (timestamp + random)
    try {
        $rand = bin2hex(random_bytes(4));
    } catch (\Throwable $e) {
        $rand = substr(md5(uniqid('', true)), 0, 8);
    }
    $id = gmdate('Ymd_His') . '_' . $rand;

    $entry = [
        "id"        => $id,
        "timestamp" => gmdate('c'),
        "meta"      => array_merge([
            "method"     => $_SERVER['REQUEST_METHOD'] ?? null,
            "uri"        => $_SERVER['REQUEST_URI'] ?? null,
            "query"      => $_GET ?? new stdClass(),
            "ip"         => $_SERVER['REMOTE_ADDR'] ?? null,
            "user_agent" => $_SERVER['HTTP_USER_AGENT'] ?? null,
            "http_code"  => $httpCode,
        ], $meta),
        "input"  => $input,
        "output" => $output,
    ];

    $path = rtrim($auditDir, '/\\') . "/audit_{$id}.json";
    @file_put_contents(
        $path,
        json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    );
}

function read_json_body_with_raw(): array
{
    $raw = file_get_contents('php://input');
    $decoded = json_decode($raw, true);

    return [
        "ok"        => is_array($decoded),
        "raw"       => $raw,
        "decoded"   => is_array($decoded) ? $decoded : null,
        "json_error" => is_array($decoded) ? null : json_last_error_msg(),
    ];
}

function respond_json_and_audit($input, $output, int $httpCode = 200, array $meta = []): void
{
    header("Content-Type: application/json; charset=utf-8");
    http_response_code($httpCode);

    audit_json_request($input, $output, $httpCode, $meta);

    echo json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

// =====================================================================
// LOG
// =====================================================================
function addLogFDV($message, $context = null)
{
    global $logFile;
    $entry = [
        "timestamp" => date("Y-m-d H:i:s"),
        "message"   => $message
    ];
    if ($context !== null) $entry["context"] = $context;
    @file_put_contents($logFile, json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
}

// ---------------------------------------------------------
// TOKEN HELPERS
// ---------------------------------------------------------
function logMsg($msg)
{
    addLogFDV($msg);
}

function fail($msg)
{
    addLogFDV("ERROR: $msg");
    exit(1);
}

// ---------------------------------------------------------
// TOKEN
// ---------------------------------------------------------
function fetchNewToken(): array
{
    global $tokenFile, $clientId, $clientSecret, $refreshToken, $tokenUrl;

    logMsg("🔄 Refresh token");

    $payload = [
        "grant_type"    => "refresh_token",
        "client_id"     => $clientId,
        "client_secret" => $clientSecret,
        "refresh_token" => $refreshToken
    ];

    $ch = curl_init($tokenUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($payload),
        CURLOPT_RETURNTRANSFER => true
    ]);

    $res = curl_exec($ch);
    if ($res === false) fail(curl_error($ch));

    $data = json_decode($res, true);

    if (!isset($data["access_token"])) {
        // Si le refresh_token lui-même est révoqué (ex: reset de mot de passe SF),
        // on logue clairement pour aider au diagnostic.
        logMsg("❌ OAuth refresh échoué — le refresh_token est peut-être révoqué (reset de mot de passe?). Réponse: " . $res);
        fail("OAuth error: " . $res);
    }

    $tokenData = [
        "access_token" => $data["access_token"],
        "instance_url" => $data["instance_url"],
        "expires_at"   => time() + 7000
    ];

    file_put_contents($tokenFile, json_encode($tokenData, JSON_PRETTY_PRINT));
    logMsg("✅ Nouveau token obtenu et sauvegardé.");

    return $tokenData;
}

function forceRefreshToken(): array
{
    global $tokenFile;
    // Supprime le cache pour forcer un vrai refresh
    if (file_exists($tokenFile)) {
        @unlink($tokenFile);
    }
    return fetchNewToken();
}

function getToken(): array
{
    global $tokenFile;

    if (file_exists($tokenFile)) {
        $data = json_decode(file_get_contents($tokenFile), true);
        if ($data && time() < ($data['expires_at'] ?? 0)) {
            logMsg("♻️ Token valide (cache)");
            return $data;
        }
    }

    return fetchNewToken();
}

/**
 * Appelé quand SF retourne 401 pendant une requête.
 * Force un refresh complet et met à jour les variables globales.
 * Retourne le nouveau token, ou null si le refresh échoue aussi.
 */
function handle401AndRefresh(): ?array
{
    global $accessToken, $instanceUrl, $tokenFile;

    logMsg("⚠️ 401 reçu — invalidation du cache token et refresh forcé.");

    // Supprime le cache
    if (file_exists($tokenFile)) {
        @unlink($tokenFile);
    }

    try {
        $newToken    = fetchNewToken();
        $accessToken = $newToken["access_token"];
        $instanceUrl = $newToken["instance_url"];
        return $newToken;
    } catch (\Throwable $e) {
        logMsg("❌ Refresh après 401 échoué : " . $e->getMessage());
        return null;
    }
}
// =====================================================================
// HELPERS HTTP + SF
// =====================================================================
function truncate_text($text, $maxLen)
{
    $text = trim((string)$text);

    if ($text === '') {
        return '';
    }

    if (mb_strlen($text) <= $maxLen) {
        return $text;
    }

    // Coupe proprement et ajoute "..."
    return mb_substr($text, 0, max(0, $maxLen - 3)) . '...';
}
function is_restricted_picklist_error($details, $fieldApiName): bool
{
    if (!is_array($details)) return false;

    foreach ($details as $e) {
        if (
            !empty($e["errorCode"]) &&
            $e["errorCode"] === "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" &&
            !empty($e["fields"]) &&
            in_array($fieldApiName, (array)$e["fields"], true)
        ) {
            return true;
        }
    }
    return false;
}

function sfHeaders($token)
{
    return [
        "Authorization: Bearer $token",
        "Content-Type: application/json",
        "Accept: application/json",
        "Sforce-Duplicate-Rule-Header: allowSave=true",
    ];
}

function http_get($url, $headers, bool $isRetry = false)
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
    ]);
    $resp = curl_exec($ch);
    if ($resp === false) {
        $err = curl_error($ch);
        curl_close($ch);
        return [0, ["error" => "CURL_GET_ERROR", "details" => $err]];
    }
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    // Retry automatique sur 401 (token révoqué côté SF)
    if ($code === 401 && !$isRetry) {
        $newToken = handle401AndRefresh();
        if ($newToken) {
            return http_get($url, sfHeaders($newToken["access_token"]), true);
        }
    }

    $decoded = json_decode($resp, true);
    if ($decoded === null && trim($resp) !== "") $decoded = ["raw" => $resp];
    return [$code, $decoded];
}

function http_post_json($url, $headers, $payload, bool $isRetry = false)
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE),
        CURLOPT_TIMEOUT        => 60,
    ]);
    $resp = curl_exec($ch);
    if ($resp === false) {
        $err = curl_error($ch);
        curl_close($ch);
        return [0, ["error" => "CURL_POST_ERROR", "details" => $err]];
    }
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    // Retry automatique sur 401
    if ($code === 401 && !$isRetry) {
        $newToken = handle401AndRefresh();
        if ($newToken) {
            return http_post_json($url, sfHeaders($newToken["access_token"]), $payload, true);
        }
    }

    $decoded = json_decode($resp, true);
    if ($decoded === null && trim($resp) !== "") $decoded = ["raw" => $resp];
    return [$code, $decoded];
}

function http_patch_json($url, $headers, $payload, bool $isRetry = false)
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => "PATCH",
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE),
        CURLOPT_TIMEOUT        => 60,
    ]);
    $resp = curl_exec($ch);
    if ($resp === false) {
        $err = curl_error($ch);
        curl_close($ch);
        return [0, ["error" => "CURL_PATCH_ERROR", "details" => $err]];
    }
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    // Retry automatique sur 401
    if ($code === 401 && !$isRetry) {
        $newToken = handle401AndRefresh();
        if ($newToken) {
            return http_patch_json($url, sfHeaders($newToken["access_token"]), $payload, true);
        }
    }
    curl_close($ch);

    if (trim($resp) === "") return [$code, []];

    $decoded = json_decode($resp, true);
    if ($decoded === null && trim($resp) !== "") $decoded = ["raw" => $resp];
    return [$code, $decoded];
}

function sf_create($sobject, $data, $instanceUrl, $accessToken, $apiVersion)
{
    $url = "$instanceUrl/services/data/$apiVersion/sobjects/$sobject/";
    return http_post_json($url, sfHeaders($accessToken), $data);
}

// =====================================================================
// Audit fields helpers (CreatedById / LastModifiedById)
// =====================================================================
// Objets SF qui ne supportent jamais les audit fields (CreatedById, etc.)
const SF_NO_AUDIT_SOBJECTS = [
    "OpportunityContactRole",
    "CampaignMember",
    "AccountContactRole",
];

function sf_apply_audit_fields_on_create(array &$payload, ?string $ownerId, ?string $meetingDateTimeIso = null, string $sobject = ''): void
{
    if (empty($ownerId)) return;

    // Certains objets ne supportent jamais les audit fields
    if (in_array($sobject, SF_NO_AUDIT_SOBJECTS, true)) return;

    $payload["CreatedById"] = $ownerId;
    $payload["LastModifiedById"] = $ownerId;

    if (!empty($meetingDateTimeIso)) {
        $payload["CreatedDate"] = $meetingDateTimeIso;
        $payload["LastModifiedDate"] = $meetingDateTimeIso;
    }
}

function sf_is_audit_field_rejected_error($resp)
{
    $raw = is_array($resp) ? json_encode($resp) : (string)$resp;
    $needles = [
        "CreatedById",
        "CreatedDate",
        "LastModifiedById",
        "LastModifiedDate",
        "not writeable",
        "INVALID_FIELD",
        "INVALID_FIELD_FOR_INSERT_UPDATE",
        "FIELD_INTEGRITY_EXCEPTION"
    ];
    foreach ($needles as $n) {
        if (stripos($raw, $n) !== false) return true;
    }
    return false;
}

function sf_create_with_agent_audit(
    string $sobject,
    array $payload,
    string $instanceUrl,
    string $accessToken,
    string $apiVersion,
    ?string $ownerId = null,
    ?string $meetingDateTimeIso = null
): array {
    if (!empty($ownerId)) {
        $payload["OwnerId"] = $ownerId;
    }

    sf_apply_audit_fields_on_create($payload, $ownerId, $meetingDateTimeIso, $sobject);
    list($code, $resp) = sf_create($sobject, $payload, $instanceUrl, $accessToken, $apiVersion);

    if ($code >= 400) {
        $err = json_encode($resp);

        if (strpos($err, "No such column 'OwnerId'") !== false) {
            unset($payload["OwnerId"]);
            list($code2, $resp2) = sf_create($sobject, $payload, $instanceUrl, $accessToken, $apiVersion);
            return [$code2, $resp2];
        }

        $auditFieldRejected =
            (strpos($err, "CreatedById") !== false) ||
            (strpos($err, "CreatedDate") !== false) ||
            (strpos($err, "LastModifiedById") !== false) ||
            (strpos($err, "LastModifiedDate") !== false) ||
            (strpos($err, "not writeable") !== false) ||
            (strpos($err, "INVALID_FIELD") !== false);

        if ($auditFieldRejected) {
            unset($payload["CreatedById"], $payload["CreatedDate"], $payload["LastModifiedById"], $payload["LastModifiedDate"]);
            list($code3, $resp3) = sf_create($sobject, $payload, $instanceUrl, $accessToken, $apiVersion);
            return [$code3, $resp3];
        }
    }

    return [$code, $resp];
}

function sf_query($soql, $instanceUrl, $accessToken, $apiVersion)
{
    $url = "$instanceUrl/services/data/$apiVersion/query?q=" . urlencode($soql);
    return http_get($url, sfHeaders($accessToken));
}

function sf_describe_fields_map($sobject, $instanceUrl, $accessToken, $apiVersion): array
{
    list($code, $res) = http_get(
        "$instanceUrl/services/data/$apiVersion/sobjects/" . rawurlencode($sobject) . "/describe",
        sfHeaders($accessToken)
    );
    if ($code >= 400 || empty($res["fields"])) return [];
    $map = [];
    foreach ($res["fields"] as $f) {
        if (!empty($f["name"])) $map[$f["name"]] = true;
    }
    return $map;
}

function sf_describe_fields_meta($sobject, $instanceUrl, $accessToken, $apiVersion): array
{
    list($code, $res) = http_get(
        "$instanceUrl/services/data/$apiVersion/sobjects/" . rawurlencode($sobject) . "/describe",
        sfHeaders($accessToken)
    );
    if ($code >= 400 || empty($res["fields"])) return [];

    $meta = [];
    foreach ((array)$res["fields"] as $f) {
        $name = $f["name"] ?? null;
        if (!$name) continue;
        $meta[$name] = [
            "name" => $name,
            "label" => (string)($f["label"] ?? $name),
            "type" => (string)($f["type"] ?? ""),
            "updateable" => !empty($f["updateable"]),
            "createable" => !empty($f["createable"]),
            "calculated" => !empty($f["calculated"]),
            "nillable" => !empty($f["nillable"]),
            "soapType" => (string)($f["soapType"] ?? ""),
            "referenceTo" => (array)($f["referenceTo"] ?? []),
            "length" => $f["length"] ?? null,
            "precision" => $f["precision"] ?? null,
            "scale" => $f["scale"] ?? null,
            "picklistValues" => (array)($f["picklistValues"] ?? []),
        ];
    }
    return $meta;
}

function normalize_field_match_key($value): string
{
    $value = strtolower(trim((string)$value));
    $value = preg_replace('/[^a-z0-9]+/', '', $value);
    return (string)$value;
}

function sf_field_types_are_compatible(array $leadField, array $oppField): bool
{
    $lt = strtolower((string)($leadField["type"] ?? ""));
    $ot = strtolower((string)($oppField["type"] ?? ""));

    if ($lt === '' || $ot === '') return false;
    if ($lt === $ot) return true;

    $groups = [
        ["string", "textarea", "encryptedstring", "url"],
        ["double", "int", "currency", "percent"],
    ];

    foreach ($groups as $group) {
        if (in_array($lt, $group, true) && in_array($ot, $group, true)) {
            return true;
        }
    }

    if ($lt === 'picklist' && $ot === 'multipicklist') return true;

    return false;
}

function sf_should_skip_auto_mapped_field(array $leadField, array $oppField): bool
{
    $oppName = (string)($oppField["name"] ?? "");
    $leadName = (string)($leadField["name"] ?? "");

    $blockedOpp = [
        "Id",
        "Name",
        "AccountId",
        "Contact__c",
        "OwnerId",
        "RecordTypeId",
        "CreatedById",
        "LastModifiedById",
        "CloseDate",
        "StageName",
        "Amount",
        "Probability",
        "Pricebook2Id",
        "CampaignId",
        "PartnerAccountId",
        "ContractId",
        "ForecastCategoryName",
        "IsPrivate",
        "IqScore",
        "TotalOpportunityQuantity",
        "SyncedQuoteId"
    ];
    $blockedLead = [
        "Id",
        "Name",
        "OwnerId",
        "RecordTypeId",
        "CreatedById",
        "LastModifiedById",
        "Address",
        "Company",
        "Status",
        "IsConverted",
        "ConvertedAccountId",
        "ConvertedContactId",
        "ConvertedOpportunityId"
    ];

    if (in_array($oppName, $blockedOpp, true)) return true;
    if (in_array($leadName, $blockedLead, true)) return true;
    if (!empty($leadField["calculated"]) || !empty($oppField["calculated"])) return true;
    if (empty($oppField["updateable"])) return true;

    return false;
}

function sf_resolve_opportunity_field_match(array $leadField, array $oppMeta): ?array
{
    $leadApi = (string)($leadField["name"] ?? '');
    $leadLabel = (string)($leadField["label"] ?? $leadApi);
    if ($leadApi === '') return null;

    foreach ($oppMeta as $oppField) {
        $oppApi = (string)($oppField["name"] ?? '');
        if ($oppApi !== '' && strtolower($oppApi) === strtolower($leadApi)) {
            return $oppField;
        }
    }

    $leadLabelKey = normalize_field_match_key($leadLabel);
    if ($leadLabelKey === '') return null;

    foreach ($oppMeta as $oppField) {
        $oppLabel = (string)($oppField["label"] ?? ($oppField["name"] ?? ''));
        if (normalize_field_match_key($oppLabel) === $leadLabelKey) {
            return $oppField;
        }
    }

    return null;
}

function sf_describe($sobject, $instanceUrl, $accessToken, $apiVersion)
{
    return http_get(
        "$instanceUrl/services/data/$apiVersion/sobjects/" . rawurlencode($sobject) . "/describe",
        sfHeaders($accessToken)
    );
}

function sf_get_default_recordtype_id_from_describe($sobject, $instanceUrl, $accessToken, $apiVersion)
{
    list($code, $desc) = sf_describe($sobject, $instanceUrl, $accessToken, $apiVersion);
    if ($code >= 400 || empty($desc["recordTypeInfos"])) {
        addLogFDV("⚠️ Describe recordTypeInfos missing", ["sobject" => $sobject, "http" => $code, "res" => $desc]);
        return null;
    }

    $infos = $desc["recordTypeInfos"];

    foreach ($infos as $rt) {
        if (!empty($rt["available"]) && !empty($rt["defaultRecordTypeMapping"]) && !empty($rt["recordTypeId"])) {
            return $rt["recordTypeId"];
        }
    }

    foreach ($infos as $rt) {
        if (!empty($rt["available"]) && !empty($rt["recordTypeId"])) {
            return $rt["recordTypeId"];
        }
    }

    return null;
}
function unit_key_variants($raw): array
{
    $raw = safe_trim($raw);
    if ($raw === "") return [];

    $variants = [];
    $push = function ($v) use (&$variants) {
        $v = safe_trim($v);
        if ($v === "") return;
        if (!in_array($v, $variants, true)) $variants[] = $v;
    };

    // 1) brute
    $push($raw);

    // 2) si format "1055_Le18Juillet-2" => "1055"
    //    ou "1055-Le18Juillet" => "1055"
    if (preg_match('/^(\d{1,10})[^\d]?.*$/', $raw, $m)) {
        $num = $m[1];
        $push($num);

        // 3) sans zéros en tête (si jamais)
        $push(ltrim($num, '0') ?: $num);
    }

    // 4) si ça contient un underscore, on prend le segment avant underscore (souvent le numéro)
    if (strpos($raw, '_') !== false) {
        $before = explode('_', $raw, 2)[0];
        $push($before);
        if (preg_match('/(\d{1,10})/', $before, $m2)) {
            $push($m2[1]);
            $push(ltrim($m2[1], '0') ?: $m2[1]);
        }
    }

    // 5) fallback : premier groupe de chiffres où qu’il soit
    if (preg_match('/(\d{1,10})/', $raw, $m3)) {
        $push($m3[1]);
        $push(ltrim($m3[1], '0') ?: $m3[1]);
    }

    return $variants;
}

function soql_escape_string($s): string
{
    // échappe les quotes pour SOQL (et backslash par prudence)
    $s = (string)$s;
    $s = str_replace("\\", "\\\\", $s);
    $s = str_replace("'", "\\'", $s);
    return $s;
}

function soql_escape_like($s): string
{
    // SOQL LIKE supporte % et _ ; on échappe le backslash + quote.
    // (Salesforce ne gère pas un ESCAPE standard comme SQL, donc on fait simple et robuste)
    return soql_escape_string($s);
}

function lookup_asset_id_for_unit_smart($unitKey, $assetFields, $instanceUrl, $accessToken, $apiVersion, $buildingHint = null)
{
    $u = safe_trim($unitKey);
    if ($u === "") return null;

    $variants = unit_key_variants($u);
    if (empty($variants)) return null;

    $candidates = [
        "Unit_Number__c",
        "UnitNumber__c",
        "Unit__c",
        "Numero__c",
        "Number__c",
        "Unit_No__c",
        "UnitId__c",
        "Name",
    ];

    // Clause bâtiment pour affiner la recherche
    $buildingClause = "";
    if (!empty($buildingHint)) {
        $bh = soql_escape_like($buildingHint);
        $buildingClause = " AND Name LIKE '%$bh%'";
    }

    // 1) EXACT MATCH avec filtre bâtiment
    foreach ($candidates as $f) {
        if (empty($assetFields[$f])) continue;
        foreach ($variants as $v) {
            $vv   = soql_escape_string($v);
            $soql = "SELECT Id FROM Asset WHERE $f = '$vv'$buildingClause LIMIT 1";
            list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
            if ($code < 400 && !empty($res["records"][0]["Id"])) {
                return $res["records"][0]["Id"];
            }
        }
    }

    // 2) Fallback sans filtre bâtiment si rien trouvé
    if (!empty($buildingClause)) {
        foreach ($candidates as $f) {
            if (empty($assetFields[$f])) continue;
            foreach ($variants as $v) {
                $vv   = soql_escape_string($v);
                $soql = "SELECT Id FROM Asset WHERE $f = '$vv' LIMIT 1";
                list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
                if ($code < 400 && !empty($res["records"][0]["Id"])) {
                    return $res["records"][0]["Id"];
                }
            }
        }
    }

    return null;
}

// =====================================================================
// UTILITAIRES
// =====================================================================
function sanitize_phone($raw)
{
    return preg_replace('/[^0-9]/', '', (string)$raw) ?: null;
}

function parse_visit_minutes($val)
{
    $s = trim((string)$val);
    if ($s === '') return 60;

    // Format HH:MM
    if (preg_match('/^(\d{1,2}):(\d{2})$/', $s, $m)) {
        return ((int)$m[1]) * 60 + (int)$m[2];
    }

    // Valeur numérique = en secondes → convertir en minutes
    if (is_numeric($s)) {
        $seconds = (float)$s;
        return max(1, (int)ceil($seconds / 60));
    }

    return 60;
}

function safe_trim($s)
{
    return trim((string)$s);
}

function full_name($first, $last)
{
    return trim(($first ?: "") . " " . ($last ?: ""));
}

function sf_datetime_utc_from_generalinfo($general): string
{
    if (!empty($general["MeetingDateTime"])) {
        $ts = strtotime((string)$general["MeetingDateTime"]);
        if ($ts !== false) return gmdate("Y-m-d\\TH:i:s\\Z", $ts);
    }

    $visitDate = !empty($general["Date"])
        ? date("Y-m-d", strtotime(str_replace('.', '-', (string)$general["Date"])))
        : gmdate("Y-m-d");

    $hhmmss = gmdate("H:i:s");
    $ts = strtotime($visitDate . " " . $hhmmss . " UTC");
    return gmdate("Y-m-d\\TH:i:s\\Z", $ts ?: time());
}

function boolish($v): bool
{
    if (is_bool($v)) return $v;
    $s = strtolower(trim((string)$v));
    return in_array($s, ["1", "true", "yes", "y", "oui"], true);
}

// =====================================================================
// BUILD ACCOUNT / CONTACT
// =====================================================================
function build_account_payload_from_client($client, $ownerId = null, $recordTypeId = null)
{
    $payload = [
        "Name"  => full_name($client["FirstName"] ?? "", $client["LastName"] ?? ""),
        "Phone" => sanitize_phone($client["Phone"] ?? null),
    ];
    if (!empty($client["Post"])) $payload["BillingPostalCode"] = safe_trim($client["Post"]);
    if (!empty($ownerId)) $payload["OwnerId"] = $ownerId;
    if (!empty($recordTypeId)) $payload["RecordTypeId"] = $recordTypeId;
    return $payload;
}

function build_contact_payload_from_client($client, $accountId = null, $ownerId = null, $recordTypeId = null)
{
    $payload = [
        "FirstName" => $client["FirstName"] ?? "",
        "LastName"  => $client["LastName"]  ?? "Inconnu",
        "Email"     => $client["Email"]     ?? null,
        "Phone"     => sanitize_phone($client["Phone"] ?? null),
    ];
    if (!empty($accountId)) $payload["AccountId"] = $accountId;
    if (!empty($ownerId))   $payload["OwnerId"]   = $ownerId;
    if (!empty($recordTypeId)) $payload["RecordTypeId"] = $recordTypeId;
    return $payload;
}

function upsert_contact_with_individual_account($client, $instanceUrl, $accessToken, $apiVersion, $ownerIdOrNull)
{
    $email = safe_trim($client["Email"] ?? "");
    $contactId = null;
    $accountId = null;

    if ($email !== "") {
        list($cCode, $cRes) = sf_query(
            "SELECT Id, AccountId FROM Contact WHERE Email = '$email' LIMIT 1",
            $instanceUrl,
            $accessToken,
            $apiVersion
        );
        if ($cCode >= 400) {
            addLogFDV("⚠️ SF query Contact by email failed", ["http" => $cCode, "res" => $cRes, "email" => $email]);
        } else if (!empty($cRes["records"][0]["Id"])) {
            $contactId = $cRes["records"][0]["Id"];
            $accountId = $cRes["records"][0]["AccountId"] ?? null;
        }
    }

    if (!$accountId) {
        list($aCode, $aRes) = sf_create_with_agent_audit(
            "Account",
            build_account_payload_from_client($client, $ownerIdOrNull, sf_pick_residential_recordtype_id("Account", $instanceUrl, $accessToken, $apiVersion)),
            $instanceUrl,
            $accessToken,
            $apiVersion,
            $ownerIdOrNull
        );
        $accountId = $aRes["id"] ?? null;
        if ($aCode >= 400 || !$accountId) {
            addLogFDV("❌ Create Account failed", ["http" => $aCode, "res" => $aRes, "client" => $client]);
            return [null, null];
        }
    }

    if (!$contactId) {
        list($cCode2, $cRes2) = sf_create_with_agent_audit(
            "Contact",
            build_contact_payload_from_client($client, $accountId, $ownerIdOrNull, sf_pick_residential_recordtype_id("Contact", $instanceUrl, $accessToken, $apiVersion)),
            $instanceUrl,
            $accessToken,
            $apiVersion,
            $ownerIdOrNull
        );
        $contactId = $cRes2["id"] ?? null;
        if ($cCode2 >= 400 || !$contactId) {
            addLogFDV("❌ Create Contact failed", ["http" => $cCode2, "res" => $cRes2, "client" => $client]);
            return [null, $accountId];
        }
    }

    return [$contactId, $accountId];
}

function upsert_contacts_with_shared_family_account($clients, $instanceUrl, $accessToken, $apiVersion, $ownerIdOrNull = null, $meetingDateTimeIso = null)
{
    if ($meetingDateTimeIso === null && !empty($GLOBALS["meetingDateTimeIso"])) {
        $meetingDateTimeIso = $GLOBALS["meetingDateTimeIso"];
    }

    $accountId  = null;
    $contactIds = [];

    $accRtId = sf_pick_residential_recordtype_id("Account", $instanceUrl, $accessToken, $apiVersion);
    $conRtId = sf_pick_residential_recordtype_id("Contact", $instanceUrl, $accessToken, $apiVersion);

    // 1) Essayer de retrouver un compte existant à partir du 1er client
    //    Priorité email, sinon fallback téléphone (MobilePhone/Phone)
    $email0 = safe_trim($clients[0]["Email"] ?? "");
    $phone0 = safe_trim($clients[0]["Phone"] ?? "");

    $existingPrimaryContact = sf_find_contact_by_email_or_phone(
        $email0,
        $phone0,
        $instanceUrl,
        $accessToken,
        $apiVersion
    );

    addLogFDV("DEBUG upsert_contacts_with_shared_family_account - primary contact lookup", [
        "email" => $email0,
        "phone" => $phone0,
        "existingPrimaryContact" => $existingPrimaryContact
    ]);

    if (!empty($existingPrimaryContact["AccountId"])) {
        $accountId = $existingPrimaryContact["AccountId"];
    }

    // 2) Si aucun Account retrouvé, on le crée
    if (empty($accountId)) {
        $accPayload = build_account_payload_from_client($clients[0], $ownerIdOrNull, $accRtId);

        list($aCode, $aRes) = sf_create_with_agent_audit(
            "Account",
            $accPayload,
            $instanceUrl,
            $accessToken,
            $apiVersion,
            $ownerIdOrNull
        );

        $accountId = $aRes["id"] ?? null;

        if ($aCode >= 400 || empty($accountId)) {
            addLogFDV("❌ Create Family Account failed", [
                "http" => $aCode,
                "res" => $aRes,
                "client" => $clients[0]
            ]);
            return [null, []];
        }

        addLogFDV("DEBUG upsert_contacts_with_shared_family_account - family account created", [
            "accountId" => $accountId,
            "payload" => $accPayload
        ]);
    } else {
        addLogFDV("DEBUG upsert_contacts_with_shared_family_account - existing family account reused", [
            "accountId" => $accountId
        ]);
    }

    // 3) Pour chaque client : retrouver Contact (email ou téléphone) puis créer si absent
    foreach ($clients as $client) {
        if (empty($client)) continue;

        $email = safe_trim($client["Email"] ?? "");
        $phone = safe_trim($client["Phone"] ?? "");

        $contactId = null;
        $existingAccountId = null;

        $existingContact = sf_find_contact_by_email_or_phone(
            $email,
            $phone,
            $instanceUrl,
            $accessToken,
            $apiVersion
        );

        addLogFDV("DEBUG upsert_contacts_with_shared_family_account - contact lookup", [
            "email" => $email,
            "phone" => $phone,
            "existingContact" => $existingContact
        ]);

        if (!empty($existingContact["Id"])) {
            $contactId = $existingContact["Id"];
            $existingAccountId = $existingContact["AccountId"] ?? null;
        }

        // 3.b) Si contact trouvé mais pas sur le bon Account, on rattache au family account
        if (!empty($contactId) && !empty($existingAccountId) && $existingAccountId !== $accountId) {
            list($uCode, $uRes) = sf_update(
                "Contact",
                $contactId,
                ["AccountId" => $accountId],
                $instanceUrl,
                $accessToken,
                $apiVersion
            );

            if ($uCode >= 400) {
                addLogFDV("⚠️ Could not re-parent existing contact to family account", [
                    "http" => $uCode,
                    "res" => $uRes,
                    "contactId" => $contactId,
                    "fromAccountId" => $existingAccountId,
                    "toAccountId" => $accountId
                ]);
            } else {
                addLogFDV("DEBUG upsert_contacts_with_shared_family_account - contact re-parented to family account", [
                    "contactId" => $contactId,
                    "fromAccountId" => $existingAccountId,
                    "toAccountId" => $accountId
                ]);
            }
        }

        // 3.c) Si contact absent, création
        if (empty($contactId)) {
            $conPayload = build_contact_payload_from_client($client, $accountId, $ownerIdOrNull, $conRtId);

            list($cCode2, $cRes2) = sf_create_with_agent_audit(
                "Contact",
                $conPayload,
                $instanceUrl,
                $accessToken,
                $apiVersion,
                $ownerIdOrNull
            );

            $contactId = $cRes2["id"] ?? null;

            if ($cCode2 >= 400 || empty($contactId)) {
                addLogFDV("❌ Create Contact failed", [
                    "http" => $cCode2,
                    "res" => $cRes2,
                    "client" => $client,
                    "accountId" => $accountId
                ]);
                continue;
            }

            addLogFDV("DEBUG upsert_contacts_with_shared_family_account - contact created", [
                "contactId" => $contactId,
                "accountId" => $accountId,
                "payload" => $conPayload
            ]);
        } else {
            addLogFDV("DEBUG upsert_contacts_with_shared_family_account - existing contact reused", [
                "contactId" => $contactId,
                "accountId" => $accountId
            ]);
        }

        if (!in_array($contactId, $contactIds, true)) {
            $contactIds[] = $contactId;
        }
    }

    return [$accountId, $contactIds];
}
// =====================================================================
// NEW: Visit__c / Visited_Unit__c helpers + Lead conversion helpers
// =====================================================================
function sf_get_recordtype_id_by_name_or_devname($sobjectType, $nameOrDevName, $instanceUrl, $accessToken, $apiVersion)
{
    $v = addslashes((string)$nameOrDevName);

    $soql = "SELECT Id, Name, DeveloperName
             FROM RecordType
             WHERE SobjectType = '$sobjectType'
             AND IsActive = true
             AND (Name = '$v' OR DeveloperName = '$v')
             LIMIT 1";

    list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
    if ($code >= 400) {
        addLogFDV("⚠️ SF query RecordType failed", ["http" => $code, "res" => $res, "sobjectType" => $sobjectType, "value" => $v]);
        return null;
    }
    return $res["records"][0]["Id"] ?? null;
}

function sf_pick_residential_recordtype_id($sobjectType, $instanceUrl, $accessToken, $apiVersion)
{
    $candidates = ["Residential", "Résidentiel", "Residentiel", "Residential_Account", "ResidentialContact"];
    foreach ($candidates as $c) {
        $id = sf_get_recordtype_id_by_name_or_devname($sobjectType, $c, $instanceUrl, $accessToken, $apiVersion);
        if (!empty($id)) return $id;
    }
    return null;
}

function sf_update($sobject, $id, $data, $instanceUrl, $accessToken, $apiVersion)
{
    $url = "$instanceUrl/services/data/$apiVersion/sobjects/$sobject/" . rawurlencode($id);
    return http_patch_json($url, sfHeaders($accessToken), $data);
}

function find_existing_visit_for_opportunity($oppId, $instanceUrl, $accessToken, $apiVersion)
{
    $soql = "SELECT Id FROM Visit__c WHERE Opportunity__c = '$oppId' ORDER BY CreatedDate DESC LIMIT 1";
    list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
    if ($code >= 400) {
        addLogFDV("⚠️ SF query Visit__c failed", ["http" => $code, "res" => $res, "oppId" => $oppId]);
        return null;
    }
    return $res["records"][0]["Id"] ?? null;
}

function find_existing_open_opportunity_for_contact_and_building(
    $contactId,
    $instanceUrl,
    $accessToken,
    $apiVersion,
    $buildingsPicklist = null,
    $buildingGroupAccountId = null,
    $days = 180
) {
    if (empty($contactId)) {
        addLogFDV("DEBUG find_existing_open_opportunity_for_contact_and_building - contactId vide", []);
        return null;
    }

    $filters = [];

    if (!empty($buildingsPicklist)) {
        $bp = str_replace("'", "\\'", (string)$buildingsPicklist);
        $filters[] = "Opportunity.Buildings__c = '$bp'";
    }
    if (!empty($buildingGroupAccountId)) {
        $filters[] = "Opportunity.Building_Group__c = '$buildingGroupAccountId'";
    }

    $whereVariants = [];

    if (!empty($filters)) {
        $whereVariants[] = implode(" AND ", $filters);
        if (count($filters) > 1) {
            $whereVariants[] = "(" . implode(" OR ", $filters) . ")";
        }
    }

    $whereVariants[] = "Id != null";

    addLogFDV("DEBUG find_existing_open_opportunity_for_contact_and_building - paramètres", [
        "contactId" => $contactId,
        "buildingsPicklist" => $buildingsPicklist,
        "buildingGroupAccountId" => $buildingGroupAccountId,
        "days" => $days,
        "whereVariants" => $whereVariants
    ]);

    foreach ($whereVariants as $buildingWhere) {
        $soql = "SELECT OpportunityId, Opportunity.Id, Opportunity.Name, Opportunity.StageName, Opportunity.CreatedDate
                 FROM OpportunityContactRole
                 WHERE ContactId = '$contactId'
                 AND Opportunity.IsClosed = false
                 AND Opportunity.CreatedDate = LAST_N_DAYS:$days
                 AND $buildingWhere
                 ORDER BY Opportunity.CreatedDate DESC
                 LIMIT 1";

        addLogFDV("DEBUG find_existing_open_opportunity_for_contact_and_building - requête", [
            "soql" => $soql
        ]);

        list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);

        addLogFDV("DEBUG find_existing_open_opportunity_for_contact_and_building - résultat", [
            "http" => $code,
            "res" => $res
        ]);

        if ($code < 400 && !empty($res["records"][0]["OpportunityId"])) {
            $oppId = $res["records"][0]["OpportunityId"];
            addLogFDV("DEBUG find_existing_open_opportunity_for_contact_and_building - opp existante retenue", [
                "oppId" => $oppId,
                "record" => $res["records"][0]
            ]);
            return $oppId;
        }
    }

    addLogFDV("DEBUG find_existing_open_opportunity_for_contact_and_building - aucune opp existante trouvée", [
        "contactId" => $contactId
    ]);

    return null;
}

function ensure_opportunity_contact_role($oppId, $contactId, $role, $isPrimary, $instanceUrl, $accessToken, $apiVersion, $ownerId = null, $meetingDateTimeIso = null)
{
    if (empty($oppId) || empty($contactId)) return [0, null];

    $soql = "SELECT Id FROM OpportunityContactRole WHERE OpportunityId = '$oppId' AND ContactId = '$contactId' LIMIT 1";
    list($qc, $qr) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
    if ($qc < 400 && !empty($qr["records"][0]["Id"])) {
        return [200, ["id" => $qr["records"][0]["Id"], "alreadyExists" => true]];
    }

    return sf_create_with_agent_audit("OpportunityContactRole", [
        "OpportunityId" => $oppId,
        "ContactId"     => $contactId,
        "Role"          => $role,
        "IsPrimary"     => (bool)$isPrimary
    ], $instanceUrl, $accessToken, $apiVersion, $ownerId, $meetingDateTimeIso);
}

function resolve_recordtype_id_for_sobject($sobjectType, $general, $instanceUrl, $accessToken, $apiVersion)
{
    if (!empty($general["VisitedUnitRecordTypeId"])) {
        return (string)$general["VisitedUnitRecordTypeId"];
    }

    if (!empty($general["VisitedUnitRecordTypeDeveloperName"])) {
        $dev = addslashes((string)$general["VisitedUnitRecordTypeDeveloperName"]);
        $soql = "SELECT Id, DeveloperName
                 FROM RecordType
                 WHERE SobjectType = '$sobjectType'
                 AND IsActive = true
                 AND DeveloperName = '$dev'
                 LIMIT 1";
        list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
        if ($code < 400 && !empty($res["records"][0]["Id"])) {
            return $res["records"][0]["Id"];
        }
    }

    $soql = "SELECT Id
             FROM RecordType
             WHERE SobjectType = '$sobjectType'
             AND IsActive = true
             ORDER BY CreatedDate ASC
             LIMIT 1";
    list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
    if ($code >= 400) {
        addLogFDV("⚠️ SF query RecordType failed", ["http" => $code, "res" => $res, "sobjectType" => $sobjectType]);
        return null;
    }
    return $res["records"][0]["Id"] ?? null;
}

function build_visit_payload(
    $visitFields,
    $oppId,
    $ownerId,
    $contact1Id,
    $contact2Id,
    $general,
    $meetingNotes,      // conservé pour compatibilité mais ignoré
    $durationMinutes,
    string $residentialBuilding = '',
    ?string $buildingGroupId    = null
) {
    $p = [];

    // Lien Opportunity
    if (!empty($visitFields["Opportunity__c"])) {
        $p["Opportunity__c"] = $oppId;
    }

    // Assignation
    if (!empty($visitFields["Assign_to__c"]) && !empty($ownerId)) {
        $p["Assign_to__c"] = $ownerId;
    }

    // Contacts
    if (!empty($visitFields["Contact_1__c"]) && !empty($contact1Id)) {
        $p["Contact_1__c"] = $contact1Id;
    }

    if (!empty($visitFields["Contact_2__c"]) && !empty($contact2Id)) {
        $p["Contact_2__c"] = $contact2Id;
    }

    // Date / Heure
    if (!empty($visitFields["Meeting_Date_Time__c"])) {
        $p["Meeting_Date_Time__c"] = sf_datetime_utc_from_generalinfo($general);
    }

    // 🔥 IMPORTANT : uniquement le Comment
    if (!empty($visitFields["Meeting_Notes__c"])) {
        $p["Meeting_Notes__c"] = (string)($general["Comment"] ?? "");
    }

    // Durée
    if (!empty($visitFields["Visit_Duration__c"])) {
        $p["Visit_Duration__c"] = (int)$durationMinutes;
    }

    // Walk-in
    if (!empty($visitFields["Walk_in__c"]) && array_key_exists("WalkIn", $general)) {
        $p["Walk_in__c"] = boolish($general["WalkIn"]);
    }

    // Immeuble résidentiel
    if (!empty($visitFields["Residential_Building__c"]) && $residentialBuilding !== '') {
        $p["Residential_Building__c"] = $residentialBuilding;
    }

    // Groupe d'immeuble
    if (!empty($visitFields["Building_Group__c"]) && !empty($buildingGroupId)) {
        $p["Building_Group__c"] = $buildingGroupId;
    }

    return $p;
}

function upsert_visit_for_opportunity(
    $oppId,
    $visitPayload,
    $instanceUrl,
    $accessToken,
    $apiVersion
) {
    list($code, $res) = sf_create_with_agent_audit(
        "Visit__c",
        $visitPayload,
        $instanceUrl,
        $accessToken,
        $apiVersion
    );

    $newId = $res["id"] ?? null;

    if ($code >= 400 || !$newId) {
        addLogFDV("❌ Create Visit__c failed", [
            "http" => $code,
            "res" => $res,
            "payload" => $visitPayload
        ]);
        return [false, "create", null, $code, $res];
    }

    // Vérification : Opportunity__c bien sauvegardé ?
    list(, $vRes) = sf_query(
        "SELECT Id, Opportunity__c FROM Visit__c WHERE Id = '" . addslashes($newId) . "' LIMIT 1",
        $instanceUrl,
        $accessToken,
        $apiVersion
    );
    $savedOppId = $vRes["records"][0]["Opportunity__c"] ?? null;

    addLogFDV("DEBUG upsert_visit - vérification Opportunity__c après création", [
        "visitId"       => $newId,
        "savedOppId"    => $savedOppId,
        "expectedOppId" => $oppId
    ]);

    // PATCH de rattrapage si le champ n'a pas été persisté
    if (!empty($oppId) && empty($savedOppId)) {
        addLogFDV("⚠️ Opportunity__c non persisté — PATCH de rattrapage", [
            "visitId" => $newId,
            "oppId"   => $oppId
        ]);
        sf_update("Visit__c", $newId, ["Opportunity__c" => $oppId], $instanceUrl, $accessToken, $apiVersion);
    }

    return [true, "create", $newId, $code, $res];
}

/**
 * Transfère dynamiquement les champs du Lead vers l'Opportunity.
 *
 * Logique de mapping (par priorité) :
 *   1. Champs dans $leadToOppExplicitMap (noms différents mais concept identique)
 *   2. Champs avec le MÊME nom d'API sur Lead et Opportunity (auto-détecté via describe)
 *
 * Un champ est transféré si :
 *   - Il n'est pas dans la liste bloquée (Id, Name, OwnerId, champs SF système...)
 *   - Il est updateable sur l'Opportunity
 *   - Les types sont compatibles
 *   - La valeur du Lead n'est pas null/vide
 *
 * Nouveaux champs ajoutés côté SF → transférés automatiquement sans toucher au code.
 */
function sf_transfer_lead_fields_to_opportunity(
    array   $leadRecord,
    string  $oppId,
    array   $leadToOppExplicitMap,
    array   $oppMeta,
    array   $oppFields,
    string  $residentialBuilding,
    ?string $buildingGroupId,
    string  $instanceUrl,
    string  $accessToken,
    string  $apiVersion
): void {
    if (empty($oppId) || empty($leadRecord)) return;

    // On a besoin des métadonnées complètes du Lead pour la comparaison de types
    $leadMeta = sf_describe_fields_meta("Lead", $instanceUrl, $accessToken, $apiVersion);
    if (empty($leadMeta)) {
        addLogFDV("⚠️ sf_transfer_lead_fields_to_opportunity - describe Lead échoué", []);
        return;
    }

    $payload    = [];
    $mappedOpp  = []; // évite de mapper deux fois le même champ Opp

    // ── ÉTAPE 1 : mapping explicite (noms différents) ──────────────────
    foreach ($leadToOppExplicitMap as $leadField => $oppField) {
        $value = $leadRecord[$leadField] ?? null;
        if ($value === null || $value === "") continue;
        if (!isset($oppMeta[$oppField])) continue;
        if (empty($oppMeta[$oppField]["updateable"])) continue;
        $payload[$oppField]   = $value;
        $mappedOpp[$oppField] = true;
    }

    // ── ÉTAPE 2 : mapping dynamique (même nom d'API) ───────────────────
    foreach ($leadMeta as $fieldName => $leadField) {

        // Déjà mappé via l'explicite → on saute
        if (isset($mappedOpp[$fieldName])) continue;

        // Le champ n'existe pas sur l'Opportunity → on saute
        if (!isset($oppMeta[$fieldName])) continue;

        $oppField = $oppMeta[$fieldName];

        // Vérifie les règles de blocage et de compatibilité
        if (sf_should_skip_auto_mapped_field($leadField, $oppField)) continue;
        if (!sf_field_types_are_compatible($leadField, $oppField)) continue;

        $value = $leadRecord[$fieldName] ?? null;
        if ($value === null || $value === "") continue;

        $payload[$fieldName]   = $value;
        $mappedOpp[$fieldName] = true;
    }

    // ── ÉTAPE 3 : bâtiment et groupe d'immeuble (résolus depuis l'Asset) ─
    if (!empty($oppFields["Residential_Building__c"]) && $residentialBuilding !== '') {
        $payload["Residential_Building__c"] = $residentialBuilding;
    }
    if (!empty($oppFields["Building_Group__c"]) && !empty($buildingGroupId)) {
        $payload["Building_Group__c"] = $buildingGroupId;
    }

    if (empty($payload)) {
        addLogFDV("ℹ️ sf_transfer_lead_fields_to_opportunity - aucun champ à transférer", [
            "oppId" => $oppId
        ]);
        return;
    }

    list($code, $resp) = sf_update("Opportunity", $oppId, $payload, $instanceUrl, $accessToken, $apiVersion);

    addLogFDV("✅ sf_transfer_lead_fields_to_opportunity - PATCH Opportunity", [
        "oppId"        => $oppId,
        "fields_count" => count($payload),
        "fields"       => array_keys($payload),
        "http"         => $code,
        "resp"         => $resp
    ]);
}

/**
 * Transfère les champs personnels du Lead vers le Contact principal.
 * Champs identiques (même nom) + champs avec noms différents.
 */
function sf_transfer_lead_fields_to_contact(
    array  $leadRecord,
    string $contactId,
    string $instanceUrl,
    string $accessToken,
    string $apiVersion
): void {
    if (empty($contactId) || empty($leadRecord)) return;

    // Mapping explicite Lead → Contact (noms différents)
    $explicitMap = [
        "Lead_s_Age__c"               => "Age__c",
        "Date_of_birth__c"            => "Birthdate",
        "Lead_Limitations__c"         => "Limitations__c",
        "Lead_Particular_Situation__c" => "Particular_situations__c",
        "Lead_Favorite_Drink__c"      => "Favorite_drink__c",
        "Lead_Persona__c"             => "Persona__c",
    ];

    // Champs avec le MÊME nom sur Lead et Contact
    $sameNameFields = [
        "Language__c",
        "Age__c",
        "Home_Phone__c",
        "Contact_Preference__c",
        "Deceased_Relative__c",
        "No_Email__c",
        "Live_Alone__c",
        "Date_of_birth__c",
        "Newsletter_Consent__c",
    ];

    $payload = [];

    foreach ($explicitMap as $leadField => $contactField) {
        $v = $leadRecord[$leadField] ?? null;
        if ($v !== null && $v !== "") $payload[$contactField] = $v;
    }

    foreach ($sameNameFields as $field) {
        $v = $leadRecord[$field] ?? null;
        if ($v !== null && $v !== "") $payload[$field] = $v;
    }

    if (empty($payload)) return;

    list($code, $resp) = sf_update("Contact", $contactId, $payload, $instanceUrl, $accessToken, $apiVersion);

    addLogFDV("✅ sf_transfer_lead_fields_to_contact - PATCH Contact", [
        "contactId"    => $contactId,
        "fields_count" => count($payload),
        "fields"       => array_keys($payload),
        "http"         => $code,
    ]);
}

/**
 * Transfère les champs animaux du Lead vers l'Account.
 * Tous les champs ont le même nom sur Lead et Account.
 */
function sf_transfer_lead_animals_to_account(
    array  $leadRecord,
    string $accountId,
    string $instanceUrl,
    string $accessToken,
    string $apiVersion
): void {
    if (empty($accountId) || empty($leadRecord)) return;

    $animalFields = [
        "Pet_type__c",
        "Pet_s_Name__c",
        "Pet_s_Race__c",
        "Pet_s_Color__c",
        "Pet_s_Age__c",
        "Pet_type_2__c",
        "Pet_s_Name_2__c",
        "Pet_s_Race_2__c",
        "Pet_s_Color_2__c",
        "Pet_s_Age_2__c",
        "ic_Pet_Weight_1__c",
        "ic_Pet_weight_2__c",
    ];

    $payload = [];
    foreach ($animalFields as $field) {
        $v = $leadRecord[$field] ?? null;
        if ($v !== null && $v !== "") $payload[$field] = $v;
    }

    if (empty($payload)) return;

    list($code, $resp) = sf_update("Account", $accountId, $payload, $instanceUrl, $accessToken, $apiVersion);

    addLogFDV("✅ sf_transfer_lead_animals_to_account - PATCH Account", [
        "accountId"    => $accountId,
        "fields_count" => count($payload),
        "fields"       => array_keys($payload),
        "http"         => $code,
    ]);
}

/**
 * Crée le 2e contact depuis le Lead et le lie à l'Opportunity via un OCR.
 * Si le contact existe déjà (même email), il est réutilisé.
 */
function sf_create_secondary_contact_from_lead(
    array  $leadRecord,
    string $accountId,
    string $oppId,
    string $ownerId,
    string $instanceUrl,
    string $accessToken,
    string $apiVersion
): void {
    $firstName = safe_trim($leadRecord["X2nd_Contact_First_Name__c"] ?? "");
    $lastName  = safe_trim($leadRecord["X2nd_Contact_Last_Name__c"]  ?? "");
    $email     = safe_trim($leadRecord["X2nd_Contact_Email__c"]      ?? "");
    $phone     = safe_trim($leadRecord["X2nd_Contact_Phone__c"]      ?? "");
    $mobile    = safe_trim($leadRecord["X2nd_Contact_Mobile__c"]     ?? "");
    $dob       = safe_trim($leadRecord["X2nd_Contact_Date_of_birth__c"] ?? "");

    // Minimum requis : au moins un nom
    if ($firstName === "" && $lastName === "") {
        addLogFDV("ℹ️ sf_create_secondary_contact_from_lead - aucun 2e contact dans le Lead", []);
        return;
    }
    if ($lastName === "") $lastName = $firstName;

    // Vérifier si un Contact existe déjà avec cet email
    $existingId = null;
    if ($email !== "") {
        $esc  = soql_escape_string($email);
        list($qc, $qr) = sf_query(
            "SELECT Id FROM Contact WHERE Email = '$esc' LIMIT 1",
            $instanceUrl,
            $accessToken,
            $apiVersion
        );
        if ($qc < 400 && !empty($qr["records"][0]["Id"])) {
            $existingId = $qr["records"][0]["Id"];
        }
    }

    if ($existingId) {
        $contactId = $existingId;
        addLogFDV("ℹ️ sf_create_secondary_contact_from_lead - 2e contact existant réutilisé", [
            "contactId" => $contactId,
            "email" => $email
        ]);
    } else {
        $contactPayload = [
            "FirstName"  => $firstName,
            "LastName"   => $lastName,
            "AccountId"  => $accountId,
            "OwnerId"    => $ownerId,
        ];
        if ($email  !== "") $contactPayload["Email"]       = $email;
        if ($phone  !== "") $contactPayload["Phone"]       = $phone;
        if ($mobile !== "") $contactPayload["MobilePhone"] = $mobile;
        if ($dob    !== "") $contactPayload["Birthdate"]   = $dob;

        list($cc, $cr) = sf_create_with_agent_audit(
            "Contact",
            $contactPayload,
            $instanceUrl,
            $accessToken,
            $apiVersion,
            $ownerId
        );

        $contactId = $cr["id"] ?? null;

        addLogFDV("✅ sf_create_secondary_contact_from_lead - 2e contact créé", [
            "contactId" => $contactId,
            "http" => $cc
        ]);
    }

    if (empty($contactId)) return;

    // Lier à l'Opportunity via OCR "Client secondaire"
    ensure_opportunity_contact_role(
        $oppId,
        $contactId,
        "Client secondaire",
        false,
        $instanceUrl,
        $accessToken,
        $apiVersion,
        $ownerId
    );
}


function sf_convert_lead($leadId, $instanceUrl, $accessToken, $apiVersion, $payload)
{
    $url = rtrim($instanceUrl, '/') . "/services/apexrest/convertLead";

    $body = [
        "leadId" => $leadId,
        "convertedStatus" => $payload["convertedStatus"] ?? "Qualified",
        "doNotCreateOpportunity" => (bool)($payload["doNotCreateOpportunity"] ?? false),
    ];

    if (!empty($payload["opportunityName"])) {
        $body["opportunityName"] = $payload["opportunityName"];
    }
    if (!empty($payload["accountId"])) {
        $body["accountId"] = $payload["accountId"];
    }
    if (!empty($payload["contactId"])) {
        $body["contactId"] = $payload["contactId"];
    }
    if (!empty($payload["ownerId"])) {
        $body["ownerId"] = $payload["ownerId"];
    }

    addLogFDV("DEBUG sf_convert_lead - URL Apex REST appelée", [
        "instanceUrl" => $instanceUrl,
        "url" => $url,
        "payload" => $body
    ]);

    list($code, $res) = http_post_json($url, sfHeaders($accessToken), $body);

    // La réponse Apex est parfois une string JSON — on force le décodage
    if (is_string($res)) {
        $decoded = json_decode($res, true);
        if (is_array($decoded)) $res = $decoded;
    }

    addLogFDV("DEBUG sf_convert_lead - réponse conversion via Apex REST", [
        "leadId" => $leadId,
        "http" => $code,
        "res" => $res
    ]);

    return [$code, $res];
}

function sf_get_lead_converted_status($instanceUrl, $accessToken, $apiVersion)
{
    return "Qualified";
}
function sf_find_contact_by_email_or_phone($email, $phone, $instanceUrl, $accessToken, $apiVersion)
{
    $e = safe_trim($email);
    $p = sanitize_phone($phone);

    // 1) Priorité email
    if ($e !== "") {
        $soql = "SELECT Id, AccountId, Email, Phone, MobilePhone, FirstName, LastName
                 FROM Contact
                 WHERE Email = '" . addslashes($e) . "'
                 ORDER BY LastModifiedDate DESC
                 LIMIT 1";

        list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);

        addLogFDV("DEBUG sf_find_contact_by_email_or_phone - search by email", [
            "email" => $e,
            "http" => $code,
            "res" => $res
        ]);

        if ($code < 400 && !empty($res["records"][0])) {
            return $res["records"][0];
        }
    }

    // 2) Fallback téléphone
    if (!empty($p)) {
        $last10 = substr($p, -10);
        $last10 = addslashes($last10);

        $soql = "SELECT Id, AccountId, Email, Phone, MobilePhone, FirstName, LastName
                 FROM Contact
                 WHERE (Phone LIKE '%$last10%' OR MobilePhone LIKE '%$last10%')
                 ORDER BY LastModifiedDate DESC
                 LIMIT 1";

        list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);

        addLogFDV("DEBUG sf_find_contact_by_email_or_phone - search by phone", [
            "phone_raw" => $phone,
            "phone_sanitized" => $p,
            "phone_last10" => $last10,
            "http" => $code,
            "res" => $res
        ]);

        if ($code < 400 && !empty($res["records"][0])) {
            return $res["records"][0];
        }
    }

    addLogFDV("DEBUG sf_find_contact_by_email_or_phone - no contact found", [
        "email" => $e,
        "phone" => $p
    ]);

    return null;
}

function sf_find_contact_by_email($email, $instanceUrl, $accessToken, $apiVersion, $phone = null)
{
    return sf_find_contact_by_email_or_phone($email, $phone, $instanceUrl, $accessToken, $apiVersion);
}

function find_existing_lead_for_clients($clients, $instanceUrl, $accessToken, $apiVersion)
{
    $emails = [];
    $phones = [];

    foreach ((array)$clients as $c) {
        $e = safe_trim($c["Email"] ?? "");
        if ($e !== "") $emails[] = addslashes($e);

        $p = sanitize_phone($c["Phone"] ?? "");
        if (!empty($p)) $phones[] = addslashes($p);
    }

    $emails = array_values(array_unique(array_filter($emails)));
    $phones = array_values(array_unique(array_filter($phones)));

    $where = [];
    if (!empty($emails)) {
        $in = "'" . implode("','", $emails) . "'";
        $where[] = "Email IN ($in)";
    }
    if (!empty($phones)) {
        $in = "'" . implode("','", $phones) . "'";
        $where[] = "Phone IN ($in)";
    }

    if (empty($where)) {
        addLogFDV("DEBUG find_existing_lead_for_clients - aucun email/téléphone exploitable", [
            "clients" => $clients
        ]);
        return null;
    }

    $soql = "SELECT Id, FirstName, LastName, Company, Email, Phone, Status, IsConverted, ConvertedOpportunityId
             FROM Lead
             WHERE IsConverted = false
             AND (" . implode(" OR ", $where) . ")
             ORDER BY CreatedDate DESC
             LIMIT 1";

    addLogFDV("DEBUG find_existing_lead_for_clients - requête Lead", [
        "soql" => $soql,
        "emails" => $emails,
        "phones" => $phones
    ]);

    list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);

    addLogFDV("DEBUG find_existing_lead_for_clients - résultat requête Lead", [
        "http" => $code,
        "res" => $res
    ]);

    if ($code >= 400) {
        addLogFDV("⚠️ SF query Lead failed", [
            "http" => $code,
            "res" => $res,
            "where" => $where
        ]);
        return null;
    }

    $lead = $res["records"][0] ?? null;

    addLogFDV("DEBUG find_existing_lead_for_clients - lead retenu", [
        "lead" => $lead
    ]);

    return $lead;
}

// =====================================================================
// HELPERS — Bâtiment / Groupe d'immeuble + Dédup Opportunité
// =====================================================================

/**
 * Extrait le nom de l'immeuble résidentiel depuis le nom d'une unité.
 * Ex: "1351_Le18Juillet-2" → "Le18Juillet-2"
 *     "503_Le21Mars-1"    → "Le21Mars-1"
 * Si aucun underscore, retourne la chaîne telle quelle.
 */
function extract_building_from_unit(string $unit): string
{
    $unit = trim($unit);
    if ($unit === '') return '';
    $pos = strpos($unit, '_');
    if ($pos !== false) {
        return substr($unit, $pos + 1);
    }
    return $unit;
}

/**
 * Résout le premier immeuble résidentiel trouvé dans les projets du payload.
 * Retourne le nom de l'immeuble (ex: "Le18Juillet-2") ou '' si introuvable.
 */
function resolve_building_from_projects(array $projects): string
{
    foreach ($projects as $proj) {
        $units = $proj["Favorites"]["Units"] ?? [];
        foreach ((array)$units as $unit) {
            $building = extract_building_from_unit(trim((string)$unit));
            if ($building !== '') return $building;
        }
    }
    return '';
}

/**
 * Cherche dans Salesforce l'Asset dont le Name correspond au nom du bâtiment
 * (ex: "Le18Juillet-2") et retourne à la fois son Id et son Building_Group__c.
 *
 * Retourne ['assetId' => '...', 'buildingGroupId' => '...'] ou les valeurs null.
 */
function lookup_asset_by_building_name(
    string $buildingName,
    string $instanceUrl,
    string $accessToken,
    string $apiVersion
): array {
    $result = ['assetId' => null, 'buildingGroupId' => null];

    $name = soql_escape_string(trim($buildingName));
    if ($name === '') return $result;

    // Exact match sur Name
    $soql = "SELECT Id, Building_Group__c FROM Asset WHERE Name = '$name' LIMIT 1";
    list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);

    if ($code < 400 && !empty($res["records"][0]["Id"])) {
        $result['assetId']        = $res["records"][0]["Id"];
        $result['buildingGroupId'] = $res["records"][0]["Building_Group__c"] ?? null;
        addLogFDV("✅ lookup_asset_by_building_name - trouvé (exact)", $result + ["building" => $buildingName]);
        return $result;
    }

    // Fallback LIKE
    $like = "%" . soql_escape_like(trim($buildingName)) . "%";
    $soql = "SELECT Id, Building_Group__c FROM Asset WHERE Name LIKE '$like' ORDER BY CreatedDate DESC LIMIT 1";
    list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);

    if ($code < 400 && !empty($res["records"][0]["Id"])) {
        $result['assetId']        = $res["records"][0]["Id"];
        $result['buildingGroupId'] = $res["records"][0]["Building_Group__c"] ?? null;
        addLogFDV("✅ lookup_asset_by_building_name - trouvé (LIKE)", $result + ["building" => $buildingName]);
        return $result;
    }

    addLogFDV("⚠️ lookup_asset_by_building_name - Asset introuvable", ["building" => $buildingName]);
    return $result;
}

/**
 * Récupère le Building_Group__c directement depuis l'Asset Salesforce
 * correspondant à une unité du projet.
 *
 * On prend la première unité trouvée dans les projets, on cherche l'Asset
 * correspondant, et on lit son champ Building_Group__c.
 *
 * Retourne l'ID du groupe d'immeuble ou null si introuvable.
 */
function lookup_asset_building_group_id(
    string $unitKey,
    array  $assetFields,
    string $instanceUrl,
    string $accessToken,
    string $apiVersion
): ?string {
    $u = safe_trim($unitKey);
    if ($u === '') return null;

    $variants = unit_key_variants($u);
    if (empty($variants)) return null;

    $candidates = [
        "Unit_Number__c",
        "UnitNumber__c",
        "Unit__c",
        "Numero__c",
        "Number__c",
        "Unit_No__c",
        "UnitId__c",
        "Name",
    ];

    // Exact match
    foreach ($candidates as $f) {
        if (empty($assetFields[$f])) continue;
        foreach ($variants as $v) {
            $vv   = soql_escape_string($v);
            $soql = "SELECT Id, Building_Group__c FROM Asset WHERE $f = '$vv' LIMIT 1";
            list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
            if ($code < 400 && !empty($res["records"][0]["Id"])) {
                $groupId = $res["records"][0]["Building_Group__c"] ?? null;
                addLogFDV("✅ lookup_asset_building_group_id - trouvé", [
                    "unitKey"  => $unitKey,
                    "assetId"  => $res["records"][0]["Id"],
                    "groupId"  => $groupId
                ]);
                return $groupId;
            }
        }
    }

    // Fallback LIKE sur Name
    if (!empty($assetFields["Name"])) {
        foreach ($variants as $v) {
            $like = "%" . soql_escape_like($v) . "%";
            $soql = "SELECT Id, Building_Group__c FROM Asset WHERE Name LIKE '$like' ORDER BY CreatedDate DESC LIMIT 1";
            list($code, $res) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
            if ($code < 400 && !empty($res["records"][0]["Id"])) {
                $groupId = $res["records"][0]["Building_Group__c"] ?? null;
                addLogFDV("✅ lookup_asset_building_group_id (LIKE) - trouvé", [
                    "unitKey"  => $unitKey,
                    "assetId"  => $res["records"][0]["Id"],
                    "groupId"  => $groupId
                ]);
                return $groupId;
            }
        }
    }

    addLogFDV("⚠️ lookup_asset_building_group_id - Asset introuvable", ["unitKey" => $unitKey]);
    return null;
}

/**
 * Recherche une Opportunité ouverte liée à un contact trouvé par email OU téléphone.
 * Retourne l'OpportunityId ou null.
 */
function find_existing_opportunity_by_client_email_or_phone(
    array   $clients,
    string  $instanceUrl,
    string  $accessToken,
    string  $apiVersion,
    int     $days            = 365,
    ?string $buildingGroupId = null   // Si fourni, un doublon est autorisé si le groupe diffère
): ?string {

    $emails = [];
    $phones = [];

    foreach ($clients as $c) {
        $e = safe_trim($c["Email"] ?? "");
        if ($e !== '') $emails[] = addslashes($e);

        $p = sanitize_phone($c["Phone"] ?? "");
        if (!empty($p)) $phones[] = addslashes($p);
    }

    $emails = array_values(array_unique(array_filter($emails)));
    $phones = array_values(array_unique(array_filter($phones)));

    if (empty($emails) && empty($phones)) {
        addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - aucun email/téléphone", []);
        return null;
    }

    $contactWhere = [];
    if (!empty($emails)) $contactWhere[] = "Email IN ('" . implode("','", $emails) . "')";
    if (!empty($phones)) $contactWhere[] = "Phone IN ('" . implode("','", $phones) . "')";

    // 1. Trouver le Contact
    $contactSoql = "SELECT Id FROM Contact
                    WHERE (" . implode(" OR ", $contactWhere) . ")
                    ORDER BY CreatedDate DESC LIMIT 1";

    addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - recherche Contact", [
        "soql" => $contactSoql
    ]);

    list($cCode, $cRes) = sf_query($contactSoql, $instanceUrl, $accessToken, $apiVersion);

    if ($cCode >= 400 || empty($cRes["records"][0]["Id"])) {
        addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - aucun Contact trouvé", [
            "http" => $cCode,
            "res" => $cRes
        ]);
        return null;
    }

    $contactId = $cRes["records"][0]["Id"];

    // Helper : vérifie si le groupe de l'Opportunity trouvée correspond au JSON.
    // Retourne l'oppId si on peut réutiliser, null si doublon autorisé.
    $checkGroup = function (string $foundOppId, ?string $foundGroupId) use ($buildingGroupId): ?string {
        if ($buildingGroupId === null) {
            // Pas de groupe dans le JSON → on réutilise toujours
            return $foundOppId;
        }
        if ($foundGroupId === $buildingGroupId) {
            addLogFDV("✅ Groupe d'immeuble identique — Opportunity réutilisée", [
                "oppId"          => $foundOppId,
                "buildingGroupId" => $buildingGroupId
            ]);
            return $foundOppId;
        }
        addLogFDV("ℹ️ Groupe d'immeuble différent — nouveau doublon autorisé", [
            "oppId"            => $foundOppId,
            "existingGroupId"  => $foundGroupId,
            "incomingGroupId"  => $buildingGroupId
        ]);
        return null; // doublon autorisé
    };

    // 2a. Via OpportunityContactRole — on lit aussi Building_Group__c
    $oppSoql = "SELECT OpportunityId, Opportunity.Building_Group__c
                FROM OpportunityContactRole
                WHERE ContactId = '$contactId'
                AND Opportunity.IsClosed = false
                AND Opportunity.CreatedDate = LAST_N_DAYS:$days
                ORDER BY Opportunity.CreatedDate DESC
                LIMIT 1";

    addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - recherche via OCR", [
        "contactId" => $contactId,
        "soql" => $oppSoql
    ]);

    list($oCode, $oRes) = sf_query($oppSoql, $instanceUrl, $accessToken, $apiVersion);

    if ($oCode < 400 && !empty($oRes["records"][0]["OpportunityId"])) {
        $foundOppId   = $oRes["records"][0]["OpportunityId"];
        $foundGroupId = $oRes["records"][0]["Opportunity"]["Building_Group__c"] ?? null;
        addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - OCR trouvée", [
            "oppId" => $foundOppId,
            "foundGroupId" => $foundGroupId
        ]);
        return $checkGroup($foundOppId, $foundGroupId);
    }

    addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - OCR vide, fallback Contact__c", [
        "http" => $oCode,
        "res" => $oRes
    ]);

    // 2b. Fallback via Opportunity.Contact__c — on lit aussi Building_Group__c
    $oppSoql2 = "SELECT Id, Building_Group__c
                 FROM Opportunity
                 WHERE Contact__c = '$contactId'
                 AND IsClosed = false
                 AND CreatedDate = LAST_N_DAYS:$days
                 ORDER BY CreatedDate DESC
                 LIMIT 1";

    addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - recherche via Contact__c", [
        "contactId" => $contactId,
        "soql" => $oppSoql2
    ]);

    list($o2Code, $o2Res) = sf_query($oppSoql2, $instanceUrl, $accessToken, $apiVersion);

    if ($o2Code < 400 && !empty($o2Res["records"][0]["Id"])) {
        $foundOppId   = $o2Res["records"][0]["Id"];
        $foundGroupId = $o2Res["records"][0]["Building_Group__c"] ?? null;
        addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - Contact__c trouvée", [
            "oppId" => $foundOppId,
            "foundGroupId" => $foundGroupId
        ]);
        return $checkGroup($foundOppId, $foundGroupId);
    }

    addLogFDV("DEBUG find_existing_opportunity_by_client_email_or_phone - aucune Opportunity trouvée", [
        "contactId" => $contactId
    ]);
    return null;
}

function normalize_unit_number($raw): string
{
    $raw = safe_trim($raw);
    if ($raw === '') return '';

    // ex: "503_Le18Juillet-1" => "503"
    if (preg_match('/(\d{1,10})/', $raw, $m)) {
        return ltrim($m[1], '0') ?: $m[1];
    }

    return $raw;
}

function build_estimate_note(array $estimate): string
{
    $included = !empty($estimate["ServicesAndOptionsIncluded"])
        ? implode(", ", (array)$estimate["ServicesAndOptionsIncluded"])
        : "";

    $options = [];
    foreach ((array)($estimate["ServicesAndOptions"] ?? []) as $opt) {
        $name  = safe_trim($opt["Name"] ?? "");
        $price = $opt["Price"] ?? null;
        if ($name !== "") {
            $options[] = $name . ($price !== null && $price !== "" ? " ({$price}$)" : "");
        }
    }

    foreach ((array)($estimate["ServicesAndOptionsMultiple"] ?? []) as $opt) {
        $name   = safe_trim($opt["Name"] ?? "");
        $number = $opt["Number"] ?? 1;
        $price  = $opt["Price"] ?? null;
        if ($name !== "") {
            $suffix = " x{$number}";
            if ($price !== null && $price !== "") {
                $suffix .= " ({$price}$)";
            }
            $options[] = $name . $suffix;
        }
    }

    $priceParts = [
        "Prix options/services: " . (string)($estimate["ServicesAndOptionsPrice"] ?? 0) . "$",
        "Prix unité: " . (string)($estimate["UnitPrice"] ?? 0) . "$",
        "Déduction: " . (string)($estimate["Deduction"] ?? 0) . "$",
        "Autres frais: " . (string)($estimate["OtherFees"] ?? 0) . "$",
        "Prix final: " . (string)($estimate["FinalPrice"] ?? 0) . "$",
    ];

    $parts = [];

    if ($included !== "") {
        $parts[] = "Services et options incluses : " . $included;
    }

    if (!empty($options)) {
        $parts[] = "Options choisies : " . implode(", ", $options);
    }

    $parts[] = "Prix : " . implode(" | ", $priceParts);

    return implode("\n", $parts);
}

function build_estimates_map_by_unit(array $projects): array
{
    $map = [];

    foreach ((array)$projects as $project) {
        foreach ((array)($project["Estimates"] ?? []) as $estimate) {
            $unit = normalize_unit_number($estimate["Unit"] ?? "");
            if ($unit === '') continue;

            $map[$unit] = $estimate;
        }
    }

    return $map;
}
function sf_get_picklist_values($sobject, $fieldApiName, $instanceUrl, $accessToken, $apiVersion): array
{
    list($code, $desc) = sf_describe($sobject, $instanceUrl, $accessToken, $apiVersion);

    if ($code >= 400 || empty($desc["fields"])) {
        addLogFDV("⚠️ sf_get_picklist_values - describe failed", [
            "sobject" => $sobject,
            "field" => $fieldApiName,
            "http" => $code,
            "res" => $desc
        ]);
        return [];
    }

    foreach ((array)$desc["fields"] as $f) {
        if (($f["name"] ?? "") !== $fieldApiName) {
            continue;
        }

        addLogFDV("DEBUG sf_get_picklist_values - champ trouvé", [
            "sobject" => $sobject,
            "field" => $fieldApiName,
            "label" => $f["label"] ?? null,
            "type" => $f["type"] ?? null,
            "restrictedPicklist" => $f["restrictedPicklist"] ?? null,
            "dependentPicklist" => $f["dependentPicklist"] ?? null,
            "controllerName" => $f["controllerName"] ?? null,
            "picklistValuesRaw" => $f["picklistValues"] ?? []
        ]);

        $values = [];
        foreach ((array)($f["picklistValues"] ?? []) as $pv) {
            addLogFDV("DEBUG sf_get_picklist_values - analyse valeur picklist", [
                "field" => $fieldApiName,
                "value" => $pv["value"] ?? null,
                "label" => $pv["label"] ?? null,
                "active" => $pv["active"] ?? null,
                "defaultValue" => $pv["defaultValue"] ?? null,
                "validFor" => $pv["validFor"] ?? null
            ]);

            if (!empty($pv["active"]) && isset($pv["value"])) {
                $values[] = (string)$pv["value"];
            }
        }

        addLogFDV("DEBUG sf_get_picklist_values - valeurs récupérées", [
            "sobject" => $sobject,
            "field" => $fieldApiName,
            "values" => $values
        ]);

        return $values;
    }

    addLogFDV("⚠️ sf_get_picklist_values - champ introuvable dans describe", [
        "sobject" => $sobject,
        "field" => $fieldApiName
    ]);

    return [];
}

function split_values_by_allowed_choices(array $values, array $allowedChoices): array
{
    $valid = [];
    $invalid = [];

    foreach ($values as $v) {
        $v = safe_trim($v);
        if ($v === '') continue;

        if (in_array($v, $allowedChoices, true)) {
            if (!in_array($v, $valid, true)) {
                $valid[] = $v;
            }
        } else {
            if (!in_array($v, $invalid, true)) {
                $invalid[] = $v;
            }
        }
    }

    return [$valid, $invalid];
}
function derive_positive_aspects_from_estimate(array $estimate): array
{
    $derived = [];

    // Avec les données actuelles, seul "Prix" est déductible de façon fiable
    if (
        isset($estimate["FinalPrice"]) ||
        isset($estimate["UnitPrice"]) ||
        isset($estimate["ServicesAndOptionsPrice"]) ||
        isset($estimate["Deduction"]) ||
        isset($estimate["OtherFees"])
    ) {
        $derived[] = "Prix";
    }

    return array_values(array_unique(array_filter(array_map('safe_trim', $derived))));
}

function create_visited_units_for_visit(
    $visitId,
    $visitedUnits,
    $defaultVisitedUnitRecordTypeId,
    $instanceUrl,
    $accessToken,
    $apiVersion,
    $visitedUnitFields,
    $assetFields,
    $estimatesByUnit = [],
    $allowedPositiveAspects = [],
    $oppId = null   // ← AJOUT
) {
    $results = [];

    foreach ((array)$visitedUnits as $vu) {
        if (is_string($vu)) {
            $vu = ["Unit" => $vu];
        }

        $unitKey = $vu["Unit"] ?? ($vu["UnitNumber"] ?? "");
        $buildingHint = extract_building_from_unit(trim((string)($vu["Unit"] ?? "")));
        $assetId = lookup_asset_id_for_unit_smart(
            $unitKey,
            $assetFields,
            $instanceUrl,
            $accessToken,
            $apiVersion,
            $buildingHint  // ← ex: "Le18Juillet-1"
        );

        if (!$assetId) {
            $results[] = [
                "ok" => false,
                "unit" => $unitKey,
                "error" => "Asset introuvable (lookup smart)."
            ];
            continue;
        }

        $payload = [];

        if (!empty($visitedUnitFields["Visite__c"])) {
            $payload["Visite__c"] = $visitId;
        }
        if (!empty($visitedUnitFields["Opportunity__c"]) && !empty($oppId)) {
            $payload["Opportunity__c"] = $oppId;
        }


        if (!empty($visitedUnitFields["Asset__c"])) {
            $payload["Asset__c"] = $assetId;
        }

        if (!empty($visitedUnitFields["RecordTypeId"]) && !empty($defaultVisitedUnitRecordTypeId)) {
            $payload["RecordTypeId"] = $defaultVisitedUnitRecordTypeId;
        }

        if (!empty($visitedUnitFields["Client_Interest__c"]) && isset($vu["ClientInterest"])) {
            $interestRaw  = (string)$vu["ClientInterest"];
            $allowedVals  = array_column(
                $visitedUnitFields["Client_Interest__c"]["picklistValues"] ?? [],
                "value"
            );
            if (in_array($interestRaw, $allowedVals, true)) {
                $payload["Client_Interest__c"] = $interestRaw;
            } else {
                // Tentative insensible à la casse
                $matched = null;
                foreach ($allowedVals as $av) {
                    if (strcasecmp($av, $interestRaw) === 0) {
                        $matched = $av;
                        break;
                    }
                }
                if ($matched) {
                    $payload["Client_Interest__c"] = $matched;
                }
                // Si aucune correspondance : champ omis, le fallback retry reste en filet de sécurité
            }
        }

        if (!empty($visitedUnitFields["Type_de_visite_c__c"]) && isset($vu["TypeDeVisite"])) {
            $payload["Type_de_visite_c__c"] = (string)$vu["TypeDeVisite"];
        }

        $notesParts = [];
        $commentsParts = [];

        if (isset($vu["Notes"]) && safe_trim($vu["Notes"]) !== "") {
            $notesParts[] = (string)$vu["Notes"];
        }

        if (isset($vu["Comments"]) && safe_trim($vu["Comments"]) !== "") {
            $commentsParts[] = (string)$vu["Comments"];
        }

        if (!empty($visitedUnitFields["Positive_Aspects__c"])) {
            $requestedPositiveAspects = [];

            // 1) Valeurs envoyées directement dans le JSON
            if (isset($vu["PositiveAspects"])) {
                if (is_array($vu["PositiveAspects"])) {
                    $requestedPositiveAspects = array_merge($requestedPositiveAspects, $vu["PositiveAspects"]);
                } else {
                    $requestedPositiveAspects[] = (string)$vu["PositiveAspects"];
                }
            }

            // 2) Valeurs dérivées depuis l'estimation de l'unité
            $normalizedUnit = normalize_unit_number($unitKey);
            if ($normalizedUnit !== '' && !empty($estimatesByUnit[$normalizedUnit])) {
                $requestedPositiveAspects = array_merge(
                    $requestedPositiveAspects,
                    derive_positive_aspects_from_estimate($estimatesByUnit[$normalizedUnit])
                );
            }

            $requestedPositiveAspects = array_values(array_unique(array_filter(array_map('safe_trim', $requestedPositiveAspects))));

            $validPositiveAspects = [];
            $invalidPositiveAspects = [];

            if (!empty($allowedPositiveAspects)) {
                list($validPositiveAspects, $invalidPositiveAspects) =
                    split_values_by_allowed_choices($requestedPositiveAspects, $allowedPositiveAspects);
            } else {
                // Fallback : on tente les valeurs demandées directement
                $validPositiveAspects = $requestedPositiveAspects;
            }

            if (!empty($validPositiveAspects)) {
                $payload["Positive_Aspects__c"] = implode(";", $validPositiveAspects);
            }

            if (!empty($invalidPositiveAspects)) {
                $notesParts[] = "Aspects positifs non présents dans les choix : " . implode(", ", $invalidPositiveAspects);
            }

            addLogFDV("DEBUG create_visited_units_for_visit - Positive_Aspects analysis", [
                "unit" => $unitKey,
                "requested" => $requestedPositiveAspects,
                "allowed" => $allowedPositiveAspects,
                "valid" => $validPositiveAspects,
                "invalid" => $invalidPositiveAspects
            ]);
        }

        if (!empty($visitedUnitFields["Notes__c"]) && !empty($notesParts)) {
            $payload["Notes__c"] = truncate_text(implode(" | ", $notesParts), 255);
        }

        if (!empty($visitedUnitFields["Comments__c"]) && !empty($commentsParts)) {
            $payload["Comments__c"] = truncate_text(implode(" | ", $commentsParts), 255);
        }

        if (!empty($visitedUnitFields["Negative_Aspects__c"]) && isset($vu["NegativeAspects"])) {
            $payload["Negative_Aspects__c"] = is_array($vu["NegativeAspects"])
                ? implode(";", $vu["NegativeAspects"])
                : (string)$vu["NegativeAspects"];
        }

        list($code, $res) = sf_create_with_agent_audit(
            "Visited_Unit__c",
            $payload,
            $instanceUrl,
            $accessToken,
            $apiVersion
        );
        $id = $res["id"] ?? null;

        if (($code >= 400 || !$id) && is_restricted_picklist_error($res, "Client_Interest__c")) {
            $rejectedInterest = $payload["Client_Interest__c"] ?? "";
            unset($payload["Client_Interest__c"]);

            if (!empty($visitedUnitFields["Notes__c"]) && $rejectedInterest !== "") {
                $fallbackNote = "Intérêt client non reconnu : " . $rejectedInterest;
                $payload["Notes__c"] = isset($payload["Notes__c"])
                    ? truncate_text($payload["Notes__c"] . " | " . $fallbackNote, 255)
                    : truncate_text($fallbackNote, 255);
            }

            list($code, $res) = sf_create_with_agent_audit(
                "Visited_Unit__c",
                $payload,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
            $id = $res["id"] ?? null;
        }

        if (($code >= 400 || !$id) && is_restricted_picklist_error($res, "Positive_Aspects__c")) {
            $rejectedPositiveAspects = [];
            if (!empty($payload["Positive_Aspects__c"])) {
                $rejectedPositiveAspects = explode(";", $payload["Positive_Aspects__c"]);
            }

            unset($payload["Positive_Aspects__c"]);

            if (!empty($visitedUnitFields["Notes__c"]) && !empty($rejectedPositiveAspects)) {
                $fallbackNote = "Aspects positifs non présents dans les choix : " . implode(", ", $rejectedPositiveAspects);

                if (!empty($payload["Notes__c"])) {
                    $payload["Notes__c"] = truncate_text($payload["Notes__c"] . " | " . $fallbackNote, 255);
                } else {
                    $payload["Notes__c"] = truncate_text($fallbackNote, 255);
                }
            }

            list($code, $res) = sf_create_with_agent_audit(
                "Visited_Unit__c",
                $payload,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
            $id = $res["id"] ?? null;
        }

        if (($code >= 400 || !$id) && is_restricted_picklist_error($res, "Type_de_visite_c__c")) {
            unset($payload["Type_de_visite_c__c"]);
            list($code, $res) = sf_create_with_agent_audit(
                "Visited_Unit__c",
                $payload,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
            $id = $res["id"] ?? null;
        }

        if (($code >= 400 || !$id) && is_restricted_picklist_error($res, "Positive_Aspects__c")) {
            unset($payload["Positive_Aspects__c"]);
            list($code, $res) = sf_create_with_agent_audit(
                "Visited_Unit__c",
                $payload,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
            $id = $res["id"] ?? null;
        }

        if (($code >= 400 || !$id) && is_restricted_picklist_error($res, "Negative_Aspects__c")) {
            unset($payload["Negative_Aspects__c"]);
            list($code, $res) = sf_create_with_agent_audit(
                "Visited_Unit__c",
                $payload,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
            $id = $res["id"] ?? null;
        }

        if ($code >= 400 || !$id) {
            addLogFDV("❌ Create Visited_Unit__c failed", [
                "http" => $code,
                "res" => $res,
                "payload" => $payload
            ]);

            $results[] = [
                "ok" => false,
                "unit" => $unitKey,
                "http" => $code,
                "res" => $res
            ];
        } else {
            $results[] = [
                "ok" => true,
                "unit" => $unitKey,
                "id" => $id,
                "http" => $code
            ];
        }
    }

    return $results;
}

// =====================================================================
// AUTH + DESCRIBE + ACTIONS (uniquement si une action est demandée)
// L'UI HTML est servie directement si aucune action n'est passée.
// =====================================================================
if (!empty($_GET["action"])) {

    $auth        = getToken();
    $accessToken = $auth["access_token"];
    $instanceUrl = $auth["instance_url"];

    // --- Ping ---
    list($pingCode, $pingRes) = http_get($instanceUrl . "/services/data/" . $apiVersion . "/", sfHeaders($accessToken));
    if ($pingCode === 0 || $pingCode >= 400) {
        addLogFDV("❌ API version/instance invalide", ["http" => $pingCode, "res" => $pingRes, "instanceUrl" => $instanceUrl ?? null, "apiVersion" => $apiVersion]);
        header("Content-Type: application/json; charset=utf-8");
        http_response_code(503);
        echo json_encode(["ok" => false, "error" => "API SF invalide ou inaccessible", "http" => $pingCode]);
        exit;
    }

    // --- Describe ---
    $oppFields        = sf_describe_fields_map("Opportunity",    $instanceUrl, $accessToken, $apiVersion);
    $visitFields      = sf_describe_fields_map("Visit__c",       $instanceUrl, $accessToken, $apiVersion);
    $visitedUnitFields = sf_describe_fields_map("Visited_Unit__c", $instanceUrl, $accessToken, $apiVersion);
    $assetFields      = sf_describe_fields_map("Asset",          $instanceUrl, $accessToken, $apiVersion);
    $leadMeta         = sf_describe_fields_meta("Lead",          $instanceUrl, $accessToken, $apiVersion);
    $oppMeta          = sf_describe_fields_meta("Opportunity",   $instanceUrl, $accessToken, $apiVersion);

    $leadToOppExplicitMap = [
        // Champs avec noms DIFFÉRENTS sur Lead et Opportunity.
        // Les champs avec le MÊME nom sont gérés automatiquement par le describe.
        "Taking_Possession__c"        => "Taking_of_possession__c",
        "Budget_minimum__c"           => "Budget_Minimum__c",
        "Current_Sector__c"           => "ic_Current_Sector__c",
        "Current_Residence__c"        => "ic_Current_Residence__c",
        "No_specific_date__c"         => "ic_No_Specific_Date__c",
        "Deceased_Relative__c"        => "ic_Deceased_Relative__c",
        "Priority_Needs_Prospect__c"  => "ic_Priority_Needs__c",
        "Number_of_Parking__c"        => "ic_Parking__c",
        "Warehouse_prospect__c"       => "Warehouse_Opportunity__c",
    ];
    // Prospect_Rank__c, Lead_Rank__c, Lead_Limitations__c,
    // Lead_Particular_Situation__c, Lead_Favorite_Drink__c, Lead_Persona__c,
    // Strategic_Prospect__c, Contact_Preference__c

    // Lead uniquement — situation actuelle (pas pertinent sur Opp) :
    // Current_situation__c, Type_of_current_property__c,
    // End_of_current_lease__c, Language__c, Business_Type__c,
    // Home_Phone__c, Client_walk_in__c, isLead__c,
    // Complete_concatenated_names__c

    // Lead uniquement — sans équivalent Opp :
    // Min_Clearance__c (Max_Clearance__c existe sur Opp, pas Min)
    // Number_of_Parking__c (ic_Parking__c sur Opp semble être un booléen)

    // Marketing Cloud — pas d'équivalent :
    // et4ae5__HasOptedOutOfMobile__c, et4ae5__Mobile_Country_Code__c,
    // Newsletter_Consent__c


    // -----------------------------------------------------------------
    // ACTION : describe_fields
    // -----------------------------------------------------------------
    if ($_GET["action"] === "describe_fields") {
        $leadFieldsFull = sf_describe_fields_meta("Lead",        $instanceUrl, $accessToken, $apiVersion);
        $oppFieldsFull  = sf_describe_fields_meta("Opportunity", $instanceUrl, $accessToken, $apiVersion);

        respond_json_and_audit([], [
            "lead_fields" => array_keys($leadFieldsFull),
            "opp_fields"  => array_keys($oppFieldsFull),
        ], 200);
    }

    // -----------------------------------------------------------------
    // ACTION : create_all
    // -----------------------------------------------------------------
    if (!empty($_GET["action"]) && $_GET["action"] === "create_all") {

        $body = read_json_body_with_raw();
        if (!$body["ok"]) {
            respond_json_and_audit(
                ["_raw" => $body["raw"], "_json_error" => $body["json_error"]],
                ["ok" => false, "error" => "Payload JSON invalide", "details" => $body["json_error"]],
                400,
                ["note" => "invalid_json"]
            );
        }

        $payload = $body["decoded"];

        $agent    = $payload["Agent"] ?? [];
        $clients  = $payload["Clients"] ?? [];
        $projects = $payload["Projects"] ?? [];
        $general  = $payload["GeneralInfo"] ?? [];

        addLogFDV("DEBUG create_all - payload reçu", [
            "Agent" => $agent,
            "Clients" => $clients,
            "Projects_count" => count((array)$projects),
            "GeneralInfo" => $general
        ]);

        // For audit fields override (if enabled): align CreatedDate/LastModifiedDate with meeting datetime
        $meetingDateTimeIso = null;
        if (!empty($general["MeetingDateTime"])) {
            $ts = strtotime((string)$general["MeetingDateTime"]);
            if ($ts !== false) {
                $meetingDateTimeIso = gmdate("Y-m-d\\TH:i:s\\Z", $ts);
            }
        }
        $GLOBALS["meetingDateTimeIso"] = $meetingDateTimeIso;

        addLogFDV("DEBUG create_all - meetingDateTimeIso calculé", [
            "MeetingDateTime_input" => $general["MeetingDateTime"] ?? null,
            "meetingDateTimeIso" => $meetingDateTimeIso
        ]);

        if (count($clients) < 1) {
            respond_json_and_audit(
                $payload,
                ["ok" => false, "error" => "Aucun client trouvé"],
                400,
                ["note" => "no_clients"]
            );
        }

        // --- Infos visite ---
        $visitNotes  = $general["Comment"] ?? "";
        $visitTimeIn = parse_visit_minutes($general["VisitTime"] ?? "");
        $unitsSeeked = !empty($general["UnitsSeeked"]) ? implode(', ', (array)$general["UnitsSeeked"]) : "";

        // --- Unités favorites (liste unique) ---
        $allFavoriteUnits = [];
        foreach ($projects as $proj) {
            if (!empty($proj["Favorites"]["Units"])) {
                foreach ((array)$proj["Favorites"]["Units"] as $u) {
                    $u = trim((string)$u);
                    if ($u !== "" && !in_array($u, $allFavoriteUnits, true)) {
                        $allFavoriteUnits[] = $u;
                    }
                }
            }
        }

        // --- Notes enrichies (Estimates condensés) ---
        $notesComplet = (string)$visitNotes;
        if (!empty($unitsSeeked))      $notesComplet .= "\nUnités recherchées : " . $unitsSeeked;
        if (!empty($allFavoriteUnits)) $notesComplet .= "\nUnités favorites : " . implode(' ', $allFavoriteUnits);

        // --- Résolution Immeuble résidentiel + Groupe d'immeuble ---
        // Nom du bâtiment extrait de la première unité (ex: "Le18Juillet-2")
        $residentialBuildingName = resolve_building_from_projects((array)$projects);

        // Lookup Asset SF par nom de bâtiment → récupère Id + Building_Group__c en 1 requête
        $residentialBuildingAssetId = null;
        $buildingGroupId            = null;

        if ($residentialBuildingName !== '') {
            $buildingLookup = lookup_asset_by_building_name(
                $residentialBuildingName,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
            $residentialBuildingAssetId = $buildingLookup['assetId'];
            $buildingGroupId            = $buildingLookup['buildingGroupId'];
        }

        // $residentialBuilding est maintenant l'ID Asset (requis par Residential_Building__c)
        $residentialBuilding = $residentialBuildingAssetId ?? '';

        addLogFDV("DEBUG create_all - bâtiment résolu", [
            "residentialBuildingName"    => $residentialBuildingName,
            "residentialBuildingAssetId" => $residentialBuildingAssetId,
            "buildingGroupId"            => $buildingGroupId
        ]);

        foreach ($projects as $p) {
            if (!empty($p["Estimates"])) {
                $notesComplet .= "\nEstimation - " . ($p["ProjectName"] ?? "Projet") . " :";
                foreach ((array)$p["Estimates"] as $e) {
                    $unit  = $e["Unit"] ?? "";
                    $final = $e["FinalPrice"] ?? 0;

                    $included = !empty($e["ServicesAndOptionsIncluded"])
                        ? implode(', ', (array)$e["ServicesAndOptionsIncluded"])
                        : "";

                    $options = [];
                    foreach ((array)($e["ServicesAndOptions"] ?? []) as $opt) {
                        $options[] = ($opt["Name"] ?? "") . " " . ($opt["Price"] ?? "") . "$";
                    }
                    foreach ((array)($e["ServicesAndOptionsMultiple"] ?? []) as $opt) {
                        $options[] = ($opt["Name"] ?? "") . " x" . ($opt["Number"] ?? 1) . " (" . ($opt["Price"] ?? 0) . "$)";
                    }

                    $optText = !empty($options) ? implode(' | ', $options) : "";
                    $notesComplet .= "\n - $unit → {$final}$ (Inclus : $included" . ($optText ? " | Options : $optText" : "") . ")";
                }
            }
        }

        // --- Agent / Owner (User) ---
        $ownerId = null;
        $agentEmail = safe_trim($agent["Email"] ?? "");
        if ($agentEmail !== "") {
            list($uCode, $uRes) = sf_query("SELECT Id FROM User WHERE Email = '$agentEmail' LIMIT 1", $instanceUrl, $accessToken, $apiVersion);

            addLogFDV("DEBUG create_all - recherche ownerId via email agent", [
                "agentEmail" => $agentEmail,
                "http" => $uCode,
                "res" => $uRes
            ]);

            if ($uCode >= 400) {
                addLogFDV("⚠️ SF query User by email failed", ["http" => $uCode, "res" => $uRes, "email" => $agentEmail]);
            } else if (!empty($uRes["records"][0]["Id"])) {
                $ownerId = $uRes["records"][0]["Id"];
            }
        }
        $GLOBALS["ownerId"] = $ownerId;

        addLogFDV("DEBUG create_all - ownerId retenu", [
            "ownerId" => $ownerId
        ]);

        // -----------------------------------------------------------------
        // ✅ LEAD FLOW
        // -----------------------------------------------------------------
        $lead = find_existing_lead_for_clients($clients, $instanceUrl, $accessToken, $apiVersion);
        $leadConversion = null;

        addLogFDV("DEBUG create_all - lead après recherche", [
            "lead" => $lead
        ]);


        $oppId = null;
        $accountMainId = null;
        $contactMainId = null;
        $contactSecondId = null;

        $rolesResp = [];
        $codeOpp = null;
        $respOpp = null;
        $leadToOpportunityAutoPayload = [];
        $leadToOpportunityAutoUpdate = null;

        // -----------------------------------------------------------------
        // ✅ DÉDUP OPPORTUNITÉ — Recherche par email/téléphone du client
        // Avant tout Lead flow ou création, on vérifie si une Opportunity
        // ouverte existe déjà pour ce client.
        // -----------------------------------------------------------------
        if (empty($oppId)) {
            $dedupOppId = find_existing_opportunity_by_client_email_or_phone(
                $clients,
                $instanceUrl,
                $accessToken,
                $apiVersion,
                365,
                $buildingGroupId  // doublon autorisé si groupe différent
            );

            if (!empty($dedupOppId)) {
                $oppId = $dedupOppId;
                addLogFDV("✅ Dédup Opportunity trouvée par email/téléphone — réutilisation", [
                    "oppId" => $oppId
                ]);

                // Mettre à jour l'Opportunity avec le bâtiment si résolu
                $oppUpdatePayload = [];
                if (!empty($oppFields["Residential_Building__c"]) && $residentialBuilding !== '') {
                    $oppUpdatePayload["Residential_Building__c"] = $residentialBuilding;
                }
                if (!empty($oppFields["Building_Group__c"]) && !empty($buildingGroupId)) {
                    $oppUpdatePayload["Building_Group__c"] = $buildingGroupId;
                }
                if (!empty($oppUpdatePayload)) {
                    sf_update("Opportunity", $oppId, $oppUpdatePayload, $instanceUrl, $accessToken, $apiVersion);
                    addLogFDV("✅ Opportunity mise à jour avec bâtiment/groupe", [
                        "oppId"   => $oppId,
                        "payload" => $oppUpdatePayload
                    ]);
                }
            }
        }

        if (!empty($lead["Id"]) && empty($oppId)) {

            addLogFDV("DEBUG create_all - lead trouvé, entrée dans le flow de conversion", [
                "leadId" => $lead["Id"],
                "leadEmail" => $lead["Email"] ?? null,
                "leadStatus" => $lead["Status"] ?? null
            ]);

            $convertedStatus = sf_get_lead_converted_status($instanceUrl, $accessToken, $apiVersion);

            addLogFDV("DEBUG create_all - convertedStatus", [
                "convertedStatus" => $convertedStatus
            ]);

            if (empty($convertedStatus)) {
                addLogFDV("❌ Impossible de déterminer le convertedStatus du Lead via /describe", []);
            } else {

                $primaryEmail = safe_trim($clients[0]["Email"] ?? "");
                $primaryPhone = safe_trim($clients[0]["Phone"] ?? "");

                $existingContact = sf_find_contact_by_email_or_phone(
                    $primaryEmail,
                    $primaryPhone,
                    $instanceUrl,
                    $accessToken,
                    $apiVersion
                );

                addLogFDV("DEBUG create_all - contact existant avant conversion Lead", [
                    "primaryEmail" => $primaryEmail,
                    "existingContact" => $existingContact
                ]);

                $convertPayload = [
                    "convertedStatus" => $convertedStatus,
                    "doNotCreateOpportunity" => false,
                    "opportunityName" => full_name($clients[0]["FirstName"] ?? "", $clients[0]["LastName"] ?? ""),
                ];
                if (!empty($ownerId)) $convertPayload["ownerId"] = $ownerId;

                if (!empty($existingContact["Id"])) {
                    $convertPayload["contactId"] = $existingContact["Id"];
                    if (!empty($existingContact["AccountId"])) {
                        $convertPayload["accountId"] = $existingContact["AccountId"];
                    }
                }

                addLogFDV("DEBUG create_all - payload conversion Lead final", [
                    "leadId" => $lead["Id"],
                    "convertPayload" => $convertPayload
                ]);

                // Nullification de TOUS les champs picklist/multipicklist du Lead
                // avant la conversion Apex. Le Lead Field Mapping natif de SF
                // peut bloquer la conversion si une valeur de picklist n'est pas
                // valide sur l'objet de destination (Contact/Opportunity).
                // Nos fonctions de transfert remettront les valeurs après conversion.
                $leadPicklistFields = [];
                $leadDescribeMeta = sf_describe_fields_meta("Lead", $instanceUrl, $accessToken, $apiVersion);
                foreach ($leadDescribeMeta as $fieldName => $fieldMeta) {
                    // Sauter les champs système et non-custom
                    if (strpos($fieldName, '__c') === false) continue;
                    if (!in_array($fieldMeta["type"] ?? '', ["picklist", "multipicklist"], true)) continue;
                    if (empty($fieldMeta["updateable"])) continue;
                    $leadPicklistFields[$fieldName] = null;
                }

                if (!empty($leadPicklistFields)) {
                    list($nullCode) = sf_update(
                        "Lead",
                        $lead["Id"],
                        $leadPicklistFields,
                        $instanceUrl,
                        $accessToken,
                        $apiVersion
                    );
                    addLogFDV("DEBUG create_all - nullification picklists Lead avant conversion", [
                        "count" => count($leadPicklistFields),
                        "fields" => array_keys($leadPicklistFields),
                        "http"   => $nullCode
                    ]);
                }

                list($cvCode, $cvRes) = sf_convert_lead($lead["Id"], $instanceUrl, $accessToken, $apiVersion, $convertPayload);
                $leadConversion = ["leadId" => $lead["Id"], "http" => $cvCode, "payload" => $convertPayload, "res" => $cvRes];

                addLogFDV("DEBUG create_all - résultat conversion Lead", [
                    "leadConversion" => $leadConversion
                ]);

                if ($cvCode < 400 && !empty($cvRes["success"])) {
                    $oppId         = $cvRes["opportunityId"] ?? null;
                    $accountMainId = $cvRes["accountId"] ?? null;
                    $contactMainId = $cvRes["contactId"] ?? null;

                    $codeOpp = 200;
                    $respOpp = ["success" => true, "errors" => [], "note" => "Opportunity issue de conversion Lead"];
                    $rolesResp = [];

                    addLogFDV("✅ Lead converti avec succès", [
                        "oppId" => $oppId,
                        "accountId" => $accountMainId,
                        "contactId" => $contactMainId
                    ]);

                    // Renseigner Contact__c + RecordType Résidentielle sur l'Opportunity issue de la conversion
                    $postConvertPatch = [];
                    if (!empty($oppFields["Contact__c"]) && !empty($contactMainId)) {
                        $postConvertPatch["Contact__c"] = $contactMainId;
                    }
                    // Forcer le RecordType Résidentielle (Apex applique le défaut de l'org sinon)
                    $_postConvertRtId = null;
                    foreach (["Résidentielle", "Residentielle", "Residential", "Résidentiel", "Residentiel"] as $_rtC) {
                        $_postConvertRtId = sf_get_recordtype_id_by_name_or_devname(
                            "Opportunity",
                            $_rtC,
                            $instanceUrl,
                            $accessToken,
                            $apiVersion
                        );
                        if (!empty($_postConvertRtId)) break;
                    }
                    if (!empty($_postConvertRtId)) {
                        $postConvertPatch["RecordTypeId"] = $_postConvertRtId;
                    }
                    if (!empty($postConvertPatch) && !empty($oppId)) {
                        sf_update("Opportunity", $oppId, $postConvertPatch, $instanceUrl, $accessToken, $apiVersion);
                        addLogFDV("✅ Opportunity post-conversion patchée (Contact__c + RecordType)", [
                            "oppId"   => $oppId,
                            "payload" => $postConvertPatch
                        ]);
                    }

                    // ✅ Transfert immédiat Lead → Opportunity
                    sf_transfer_lead_fields_to_opportunity(
                        $lead,
                        $oppId,
                        $leadToOppExplicitMap,
                        $oppMeta,
                        $oppFields,
                        $residentialBuilding,
                        $buildingGroupId,
                        $instanceUrl,
                        $accessToken,
                        $apiVersion
                    );

                    // ✅ Champs personnels Lead → Contact principal
                    sf_transfer_lead_fields_to_contact(
                        $lead,
                        $contactMainId,
                        $instanceUrl,
                        $accessToken,
                        $apiVersion
                    );

                    // ✅ Animaux Lead → Account
                    sf_transfer_lead_animals_to_account(
                        $lead,
                        $accountMainId,
                        $instanceUrl,
                        $accessToken,
                        $apiVersion
                    );

                    // ✅ 2e contact Lead → Contact SF + OCR sur Opportunity
                    sf_create_secondary_contact_from_lead(
                        $lead,
                        $accountMainId,
                        $oppId,
                        $ownerId,
                        $instanceUrl,
                        $accessToken,
                        $apiVersion
                    );
                } else {
                    addLogFDV("❌ Lead conversion failed; fallback to classic create", ["leadConversion" => $leadConversion]);
                }
            }
        } else {
            addLogFDV("DEBUG create_all - aucun lead trouvé, passage direct au flow classique", [
                "clients" => $clients
            ]);
        }

        // -----------------------------------------------------------------
        // CLASSIC FLOW si pas d'Opportunity issue de conversion
        // -----------------------------------------------------------------
        if (empty($oppId)) {
            addLogFDV("DEBUG create_all - entrée dans le flow classique", []);

            [$accountMainId, $contactIds] = upsert_contacts_with_shared_family_account($clients, $instanceUrl, $accessToken, $apiVersion, $ownerId);

            addLogFDV("DEBUG create_all - résultat upsert family account/contact", [
                "accountMainId" => $accountMainId,
                "contactIds" => $contactIds
            ]);

            $contactMainId   = $contactIds[0] ?? null;
            $contactSecondId = $contactIds[1] ?? null;

            if (!$accountMainId || !$contactMainId) {
                respond_json_and_audit(
                    $payload,
                    [
                        "ok" => false,
                        "error" => "Échec création Account/Contact principal",
                        "leadFound" => !empty($lead["Id"]),
                        "LeadConversion" => $leadConversion
                    ],
                    500,
                    ["note" => "account_contact_fail"]
                );
                exit; // Bug 3 fix — stoppe l'exécution si Account/Contact non créés
            }

            $oppPayload = [
                "Name"        => full_name($clients[0]["FirstName"] ?? "", $clients[0]["LastName"] ?? ""),
                "StageName"   => "Qualification",
                "CloseDate"   => date("Y-m-d", strtotime("+30 days")),
                "AccountId"   => $accountMainId,
                "Description" => (string)$visitNotes,
            ];

            if (!empty($oppFields["Contact__c"])) {
                $oppPayload["Contact__c"] = $contactMainId;
            }

            if (!empty($oppFields["Buildings__c"]) && !empty($general["Building"])) {
                $oppPayload["Buildings__c"] = (string)$general["Building"];
            }

            if (!empty($ownerId)) {
                $oppPayload["OwnerId"] = $ownerId;
            }

            // Immeuble résidentiel
            if (!empty($oppFields["Residential_Building__c"]) && $residentialBuilding !== '') {
                $oppPayload["Residential_Building__c"] = $residentialBuilding;
            }

            // Groupe d'immeuble
            if (!empty($oppFields["Building_Group__c"]) && !empty($buildingGroupId)) {
                $oppPayload["Building_Group__c"] = $buildingGroupId;
            }

            $oppResidentialRtId = null;
            foreach (["Résidentielle", "Residentielle", "Residential", "Résidentiel", "Residentiel"] as $_rtCandidate) {
                $oppResidentialRtId = sf_get_recordtype_id_by_name_or_devname(
                    "Opportunity",
                    $_rtCandidate,
                    $instanceUrl,
                    $accessToken,
                    $apiVersion
                );
                if (!empty($oppResidentialRtId)) break;
            }

            if (!empty($oppResidentialRtId)) {
                $oppPayload["RecordTypeId"] = $oppResidentialRtId;
            } else {
                addLogFDV("⚠️ RecordType Opportunity 'Residential' introuvable (ou non accessible). Opportunity créée sans RecordTypeId.");
            }

            addLogFDV("DEBUG create_all - oppPayload avant recherche d'opp existante", [
                "oppPayload" => $oppPayload,
                "contactMainId" => $contactMainId
            ]);

            $existingOppId = find_existing_open_opportunity_for_contact_and_building(
                $contactMainId,
                $instanceUrl,
                $accessToken,
                $apiVersion,
                $oppPayload["Buildings__c"] ?? null,
                $oppPayload["Building_Group__c"] ?? null,
                180
            );

            addLogFDV("DEBUG create_all - existingOppId", [
                "existingOppId" => $existingOppId
            ]);

            if (!empty($existingOppId)) {
                $oppId = $existingOppId;
                addLogFDV("✅ Existing Opportunity found (avoid duplicate)", [
                    "oppId" => $oppId,
                    "contactId" => $contactMainId,
                    "Buildings__c" => $oppPayload["Buildings__c"] ?? null
                ]);

                // Bug 1 fix — mettre à jour Immeuble et Groupe sur l'Opp existante
                $existingOppUpdate = [];
                if (!empty($oppFields["Residential_Building__c"]) && $residentialBuilding !== '') {
                    $existingOppUpdate["Residential_Building__c"] = $residentialBuilding;
                }
                if (!empty($oppFields["Building_Group__c"]) && !empty($buildingGroupId)) {
                    $existingOppUpdate["Building_Group__c"] = $buildingGroupId;
                }
                if (!empty($existingOppUpdate)) {
                    sf_update("Opportunity", $oppId, $existingOppUpdate, $instanceUrl, $accessToken, $apiVersion);
                    addLogFDV("✅ Opp existante — bâtiment et groupe mis à jour", [
                        "oppId" => $oppId,
                        "payload" => $existingOppUpdate
                    ]);
                }

                if (!empty($lead["Id"]) && empty($lead["IsConverted"])) {
                    $convertedStatus = sf_get_lead_converted_status($instanceUrl, $accessToken, $apiVersion);
                    if (!empty($convertedStatus)) {

                        // Bug 4 fix — résoudre Contact et Account depuis le Lead AVANT la conversion
                        $leadEmail = safe_trim($lead["Email"] ?? "");
                        $leadPhone = safe_trim($lead["Phone"] ?? "");
                        $resolvedContact = sf_find_contact_by_email_or_phone(
                            $leadEmail,
                            $leadPhone,
                            $instanceUrl,
                            $accessToken,
                            $apiVersion
                        );

                        $convertPayload = [
                            "convertedStatus"        => $convertedStatus,
                            "doNotCreateOpportunity" => true
                        ];

                        if (!empty($resolvedContact["Id"])) {
                            $convertPayload["contactId"] = $resolvedContact["Id"];
                            $contactMainId = $resolvedContact["Id"];
                            if (!empty($resolvedContact["AccountId"])) {
                                $convertPayload["accountId"] = $resolvedContact["AccountId"];
                                $accountMainId = $resolvedContact["AccountId"];
                            }
                        }

                        if (!empty($ownerId)) $convertPayload["ownerId"] = $ownerId;

                        addLogFDV("DEBUG create_all - tentative conversion Lead sans création d'Opportunity", [
                            "leadId" => $lead["Id"],
                            "convertPayload" => $convertPayload
                        ]);

                        list($lcCode, $lcResp) = sf_convert_lead($lead["Id"], $instanceUrl, $accessToken, $apiVersion, $convertPayload);
                        $leadConversion = ["http" => $lcCode, "resp" => $lcResp, "mode" => "convert_no_opportunity"];

                        // Si la conversion a créé un nouveau Contact (lead sans contact existant)
                        if (is_array($lcResp) && !empty($lcResp["contactId"]) && empty($contactMainId)) {
                            $contactMainId = $lcResp["contactId"];
                            $accountMainId = $lcResp["accountId"] ?? $accountMainId;
                        }

                        addLogFDV("ℹ️ Lead converted without Opportunity (existing opp reused)", $leadConversion);
                    } else {
                        addLogFDV("⚠️ Impossible de déterminer convertedStatus pour Lead. Conversion ignorée (opp existante réutilisée).", ["leadId" => $lead["Id"]]);
                    }
                }
            }

            if (empty($oppId)) {
                addLogFDV("DEBUG create_all - aucune opp existante, création Opportunity", [
                    "oppPayload" => $oppPayload
                ]);

                list($codeOpp, $respOpp) = sf_create_with_agent_audit(
                    "Opportunity",
                    $oppPayload,
                    $instanceUrl,
                    $accessToken,
                    $apiVersion,
                    $ownerId,
                    $meetingDateTimeIso ?? null
                );
                $oppId = $respOpp["id"] ?? null;

                addLogFDV("DEBUG create_all - réponse création Opportunity", [
                    "http" => $codeOpp,
                    "resp" => $respOpp,
                    "oppId" => $oppId
                ]);

                if (($codeOpp >= 400 || !$oppId) && is_restricted_picklist_error($respOpp, "Buildings__c")) {
                    addLogFDV("⚠️ Buildings__c invalide, retry sans Buildings__c", ["value" => $oppPayload["Buildings__c"] ?? null]);
                    unset($oppPayload["Buildings__c"]);

                    list($codeOpp, $respOpp) = sf_create_with_agent_audit(
                        "Opportunity",
                        $oppPayload,
                        $instanceUrl,
                        $accessToken,
                        $apiVersion,
                        $ownerId,
                        $meetingDateTimeIso ?? null
                    );
                    $oppId = $respOpp["id"] ?? null;

                    addLogFDV("DEBUG create_all - réponse création Opportunity après retry", [
                        "http" => $codeOpp,
                        "resp" => $respOpp,
                        "oppId" => $oppId
                    ]);
                }

                if ($codeOpp >= 400 || !$oppId) {
                    addLogFDV("❌ Create Opportunity failed", ["http" => $codeOpp, "res" => $respOpp, "payload" => $oppPayload]);
                    respond_json_and_audit(
                        $payload,
                        ["ok" => false, "error" => "Échec création Opportunity", "details" => $respOpp],
                        500,
                        ["note" => "opportunity_fail"]
                    );
                }
            }

            $roles = [
                ["ContactId" => $contactMainId,   "Role" => "Client principal",  "IsPrimary" => true],
                ["ContactId" => $contactSecondId, "Role" => "Client secondaire", "IsPrimary" => false],
            ];
            $rolesResp = [];
            foreach ($roles as $r) {
                if (!$r["ContactId"]) continue;
                list($rc, $rr) = ensure_opportunity_contact_role(
                    $oppId,
                    $r["ContactId"],
                    $r["Role"],
                    $r["IsPrimary"],
                    $instanceUrl,
                    $accessToken,
                    $apiVersion,
                    $ownerId,
                    $meetingDateTimeIso ?? null
                );
                $rolesResp[] = ["http" => $rc, "resp" => $rr, "role" => $r];
            }
        }

        // Transfert Lead → Opportunity pour le cas dédup (Opportunity existante réutilisée).
        // Pour le cas conversion réussie, le transfert est déjà fait immédiatement après
        // la conversion (dans le bloc sf_convert_lead ci-dessus).
        if (!empty($lead["Id"]) && !empty($oppId) && !empty($leadToOppExplicitMap) && empty($leadConversion)) {

            // On est dans le cas dédup : le Lead existe mais pas de conversion (opp déjà existante).
            // On recharge le Lead pour avoir tous ses champs.
            $fieldsToFetch = array_keys($leadToOppExplicitMap);
            $soql = "SELECT " . implode(", ", $fieldsToFetch)
                . " FROM Lead WHERE Id = '" . addslashes($lead["Id"]) . "' LIMIT 1";

            list($lCode, $lRes) = sf_query($soql, $instanceUrl, $accessToken, $apiVersion);
            $leadRecord = $lRes["records"][0] ?? [];

            sf_transfer_lead_fields_to_opportunity(
                $leadRecord,
                $oppId,
                $leadToOppExplicitMap,
                $oppMeta,
                $oppFields,
                $residentialBuilding,
                $buildingGroupId,
                $instanceUrl,
                $accessToken,
                $apiVersion
            );
        }

        // -----------------------------------------------------------------
        // ✅ Create / Update Visit__c liée à l'Opportunity
        // -----------------------------------------------------------------
        $visitPayload = build_visit_payload(
            $visitFields,
            $oppId,
            $ownerId,
            $contactMainId,
            $contactSecondId,
            $general,
            $notesComplet,
            $visitTimeIn,
            $residentialBuilding,
            $buildingGroupId
        );

        addLogFDV("DEBUG create_all - visitPayload final", [
            "visitPayload" => $visitPayload
        ]);

        if (empty($visitPayload["Opportunity__c"])) {
            addLogFDV("❌ Visit__c payload missing Opportunity__c", ["visitFields" => $visitFields]);
            respond_json_and_audit(
                $payload,
                ["ok" => false, "error" => "Visit__c: champ Opportunity__c introuvable via describe"],
                500,
                ["note" => "visit_field_missing"]
            );
        }

        list($okVisit, $visitMode, $visitId, $visitHttp, $visitRes) =
            upsert_visit_for_opportunity($oppId, $visitPayload, $instanceUrl, $accessToken, $apiVersion);

        addLogFDV("DEBUG create_all - résultat upsert Visit__c", [
            "okVisit" => $okVisit,
            "visitMode" => $visitMode,
            "visitId" => $visitId,
            "visitHttp" => $visitHttp,
            "visitRes" => $visitRes
        ]);

        if (!$okVisit || !$visitId) {
            respond_json_and_audit(
                $payload,
                [
                    "ok" => false,
                    "error" => "Échec création/mise à jour Visit__c",
                    "mode" => $visitMode,
                    "http" => $visitHttp,
                    "details" => $visitRes,
                    "payload" => $visitPayload
                ],
                500,
                ["note" => "visit_fail"]
            );
        }

        // -----------------------------------------------------------------
        // ✅ Create Visited_Unit__c
        // -----------------------------------------------------------------
        $visitedUnits = $general["VisitedUnits"] ?? null;
        if (empty($visitedUnits)) {
            $visitedUnits = $allFavoriteUnits;
        }

        addLogFDV("DEBUG create_all - visitedUnits source finale", [
            "visitedUnits" => $visitedUnits
        ]);

        $defaultVURecordTypeId = null;

        // 1) Priorité : .env (spécifique à l'environnement)
        if (!empty($_ENV["SF_VISITED_UNIT_RECORD_TYPE_ID"])) {
            $defaultVURecordTypeId = $_ENV["SF_VISITED_UNIT_RECORD_TYPE_ID"];
            addLogFDV("✅ RecordTypeId Visited_Unit__c depuis .env", [
                "id" => $defaultVURecordTypeId
            ]);

            // 2) Fallback : résolution dynamique via DeveloperName Residential
        } elseif (!empty($visitedUnitFields["RecordTypeId"])) {
            $defaultVURecordTypeId = sf_get_recordtype_id_by_name_or_devname(
                "Visited_Unit__c",
                "Residential_Visited_Unit",
                $instanceUrl,
                $accessToken,
                $apiVersion
            );

            // 3) Fallback final : premier RecordType actif
            if (empty($defaultVURecordTypeId)) {
                $defaultVURecordTypeId = sf_get_default_recordtype_id_from_describe(
                    "Visited_Unit__c",
                    $instanceUrl,
                    $accessToken,
                    $apiVersion
                );
            }
        }

        addLogFDV("DEBUG create_all - defaultVURecordTypeId", [
            "defaultVURecordTypeId" => $defaultVURecordTypeId
        ]);

        if (!empty($visitedUnitFields["RecordTypeId"]) && empty($defaultVURecordTypeId)) {
            addLogFDV("⚠️ No accessible RecordTypeId for Visited_Unit__c — Visited_Unit__c will be skipped", [
                "note" => "RecordTypeInfos empty or unavailable for this user"
            ]);
            $visitedUnitsResp = [["ok" => false, "error" => "RecordTypeId requis mais introuvable via JSON/describe"]];
        } else {
            $estimatesByUnit = build_estimates_map_by_unit($projects);

            addLogFDV("DEBUG create_all - estimatesByUnit", [
                "estimatesByUnit" => $estimatesByUnit
            ]);
            $visitedUnitsResp = create_visited_units_for_visit(
                $visitId,
                $visitedUnits,
                $defaultVURecordTypeId,
                $instanceUrl,
                $accessToken,
                $apiVersion,
                $visitedUnitFields,
                $assetFields,
                $estimatesByUnit,
                [],        // allowedPositiveAspects (déjà à [] par défaut, mais on l'explicite)
                $oppId     // ← AJOUT
            );
        }

        addLogFDV("✅ create_all executed", [
            "OpportunityId" => $oppId,
            "VisitId" => $visitId,
            "VisitMode" => $visitMode,
            "LeadConversion" => $leadConversion,
            "VisitedUnits" => $visitedUnitsResp,
        ]);

        $final = [
            "ok"             => true,
            "LeadConversion" => $leadConversion,
            "LeadToOpportunityAutoPayload" => $leadToOpportunityAutoPayload,
            "LeadToOpportunityAutoUpdate" => $leadToOpportunityAutoUpdate,
            "Opportunity"    => ["http" => $codeOpp, "resp" => $respOpp, "id" => $oppId],
            "Roles"          => $rolesResp,
            "Visit__c"       => [
                "mode" => $visitMode,
                "id" => $visitId,
                "http" => $visitHttp,
                "payload" => $visitPayload
            ],
            "Visited_Unit__c" => $visitedUnitsResp
        ];

        respond_json_and_audit(
            $payload,
            $final,
            200,
            ["note" => "create_all", "OpportunityId" => $oppId ?? null, "VisitId" => $visitId ?? null]
        );
    }
} // fin if (!empty($_GET["action"]))
?>
<!DOCTYPE html>
<html lang="fr">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>Fin de Visite — Test JSON</title>
    <style>
        :root {
            --bg0: #070A12;
            --bg1: #0B1220;
            --panel: rgba(255, 255, 255, .06);
            --panel2: rgba(255, 255, 255, .08);
            --stroke: rgba(255, 255, 255, .10);
            --muted: rgba(255, 255, 255, .62);
            --text: rgba(255, 255, 255, .92);
            --accent: #6C63FF;
            --accent2: #22C55E;
            --bad: #EF4444;
            --warn: #F59E0B;
            --shadow: 0 18px 45px rgba(0, 0, 0, .40);
            --radius: 18px;
        }

        * {
            box-sizing: border-box;
        }

        body {
            margin: 0;
            font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
            background:
                radial-gradient(800px 500px at 20% -10%, rgba(108, 99, 255, .22), transparent 60%),
                radial-gradient(900px 500px at 100% 0%, rgba(34, 197, 94, .16), transparent 60%),
                linear-gradient(180deg, var(--bg0), var(--bg1));
            color: var(--text);
        }

        .wrap {
            max-width: 1200px;
            margin: 0 auto;
            padding: 28px 18px 52px;
        }

        .topbar {
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 14px;
            margin-bottom: 16px;
        }

        .title {
            display: grid;
            gap: 6px;
        }

        h1 {
            font-size: 20px;
            margin: 0;
            letter-spacing: .2px;
        }

        .subtitle {
            color: var(--muted);
            font-size: 13px;
            line-height: 1.45;
        }

        .chips {
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
            justify-content: flex-end;
        }

        .chip {
            font-size: 12px;
            color: var(--muted);
            border: 1px solid var(--stroke);
            background: rgba(255, 255, 255, .04);
            padding: 7px 10px;
            border-radius: 999px;
            backdrop-filter: blur(8px);
        }

        .grid {
            display: grid;
            grid-template-columns: 1.15fr .85fr;
            gap: 14px;
        }

        @media (max-width: 980px) {
            .grid {
                grid-template-columns: 1fr;
            }

            .chips {
                justify-content: flex-start;
            }
        }

        .card {
            background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .04));
            border: 1px solid var(--stroke);
            border-radius: var(--radius);
            box-shadow: var(--shadow);
            overflow: hidden;
        }

        .card-h {
            padding: 14px 14px 12px;
            border-bottom: 1px solid rgba(255, 255, 255, .08);
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 12px;
            background: rgba(255, 255, 255, .03);
        }

        .card-h strong {
            font-size: 13px;
            letter-spacing: .3px;
        }

        .pill {
            font-size: 12px;
            padding: 6px 10px;
            border-radius: 999px;
            border: 1px solid var(--stroke);
            color: var(--muted);
            background: rgba(255, 255, 255, .04);
            white-space: nowrap;
        }

        .card-b {
            padding: 12px 14px 14px;
        }

        .toolbar {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            align-items: center;
            margin-bottom: 10px;
        }

        button {
            cursor: pointer;
            border: 0;
            border-radius: 14px;
            padding: 10px 12px;
            font-weight: 700;
            color: white;
            transition: transform .05s ease, filter .2s ease, background .2s ease;
            user-select: none;
        }

        button:active {
            transform: translateY(1px);
        }

        button:disabled {
            opacity: .55;
            cursor: not-allowed;
            filter: grayscale(30%);
        }

        .btn-primary {
            background: linear-gradient(180deg, rgba(108, 99, 255, 1), rgba(108, 99, 255, .75));
            box-shadow: 0 10px 24px rgba(108, 99, 255, .18);
        }

        .btn-primary:hover {
            filter: brightness(1.05);
        }

        .btn-ghost {
            background: rgba(255, 255, 255, .06);
            border: 1px solid var(--stroke);
            color: var(--text);
        }

        .btn-ghost:hover {
            background: rgba(255, 255, 255, .09);
        }

        .status {
            margin-left: auto;
            font-size: 12.5px;
            color: var(--muted);
            display: flex;
            align-items: center;
            gap: 8px;
            min-height: 22px;
        }

        .dot {
            width: 8px;
            height: 8px;
            border-radius: 999px;
            background: rgba(255, 255, 255, .25);
            box-shadow: 0 0 0 3px rgba(255, 255, 255, .06);
        }

        .status.ok {
            color: rgba(34, 197, 94, .95);
        }

        .status.ok .dot {
            background: rgba(34, 197, 94, 1);
            box-shadow: 0 0 0 3px rgba(34, 197, 94, .15);
        }

        .status.bad {
            color: rgba(239, 68, 68, .95);
        }

        .status.bad .dot {
            background: rgba(239, 68, 68, 1);
            box-shadow: 0 0 0 3px rgba(239, 68, 68, .15);
        }

        .status.warn {
            color: rgba(245, 158, 11, .95);
        }

        .status.warn .dot {
            background: rgba(245, 158, 11, 1);
            box-shadow: 0 0 0 3px rgba(245, 158, 11, .15);
        }

        textarea,
        pre {
            width: 100%;
            min-height: 560px;
            resize: vertical;
            background: rgba(0, 0, 0, .25);
            color: var(--text);
            border: 1px solid rgba(255, 255, 255, .12);
            border-radius: 14px;
            padding: 12px;
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
            font-size: 12.6px;
            line-height: 1.35;
            outline: none;
        }

        pre {
            margin: 0;
            overflow: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
        }

        .small {
            margin-top: 10px;
            font-size: 12px;
            color: var(--muted);
            line-height: 1.45;
        }

        .kbd {
            font-family: ui-monospace, monospace;
            font-size: 12px;
            background: rgba(255, 255, 255, .08);
            padding: 2px 7px;
            border-radius: 10px;
            border: 1px solid rgba(255, 255, 255, .12);
            color: rgba(255, 255, 255, .85);
        }

        .grow {
            flex: 1;
        }
    </style>
</head>

<body>
    <div class="wrap">
        <div class="topbar">
            <div class="title">
                <h1>🧩 Fin de Visite — Test JSON</h1>
                <div class="subtitle">
                    Colle/édite ton JSON puis clique <span class="kbd">Envoyer</span>.
                    Requête <span class="kbd">POST</span> vers <span class="kbd">?action=create_all</span>.
                    Le résultat s’affiche à droite.
                </div>
            </div>
            <div class="chips">
                <div class="chip">Sandbox</div>
                <div class="chip">Salesforce REST</div>
                <div class="chip" id="httpInfo">HTTP —</div>
            </div>
        </div>

        <div class="grid">
            <div class="card">
                <div class="card-h">
                    <strong>Entrée JSON</strong>
                    <span class="pill" id="hint">POST → ?action=create_all</span>
                </div>
                <div class="card-b">
                    <div class="toolbar">
                        <button id="sendBtn" class="btn-primary" onclick="sendJSON()">🚀 Envoyer la requête</button>
                        <button class="btn-ghost" onclick="formatJSON()">✨ Formatter</button>
                        <button class="btn-ghost" onclick="loadExample()">↩️ Recharger l’exemple</button>

                        <div id="status" class="status grow">
                            <span class="dot"></span>
                            <span>Prêt.</span>
                        </div>
                    </div>

                    <textarea id="jsonInput" spellcheck="false"><?php
                                                                echo htmlspecialchars(json_encode([
                                                                    "Agent" => [
                                                                        "FirstName" => "Yannick",
                                                                        "LastName"  => "Gilbert",
                                                                        "Email"     => "yannick@minuitmoinsune.com",
                                                                        "Job"       => "Développeur"
                                                                    ],
                                                                    "Clients" => [
                                                                        [
                                                                            "FirstName" => "Camille",
                                                                            "LastName"  => "Durand",
                                                                            "Email"     => "camille.durand@example.com",
                                                                            "Phone"     => "514-777-4455",
                                                                            "Post"      => "H2L 3A6"
                                                                        ],
                                                                        [
                                                                            "FirstName" => "Louis",
                                                                            "LastName"  => "Martel",
                                                                            "Email"     => "louis.martel@example.com",
                                                                            "Phone"     => "438-654-9922",
                                                                            "Post"      => "H3Z 1A5"
                                                                        ]
                                                                    ],
                                                                    "Projects" => [
                                                                        [
                                                                            "ProjectName" => "Esplanade Cartier - Phase 1",
                                                                            "Favorites" => ["Units" => ["203", "305", "402"]],
                                                                            "Estimates" => [
                                                                                [
                                                                                    "Unit" => "203",
                                                                                    "FinalPrice" => 1920,
                                                                                    "ServicesAndOptionsIncluded" => ["Internet", "Chauffage"],
                                                                                    "ServicesAndOptions" => [["Name" => "Stationnement", "Price" => 100]],
                                                                                    "ServicesAndOptionsMultiple" => []
                                                                                ],
                                                                                [
                                                                                    "Unit" => "305",
                                                                                    "FinalPrice" => 2200,
                                                                                    "ServicesAndOptionsIncluded" => ["Climatisation"],
                                                                                    "ServicesAndOptions" => [],
                                                                                    "ServicesAndOptionsMultiple" => [["Name" => "Rangement", "Number" => 2, "Price" => 80]]
                                                                                ]
                                                                            ]
                                                                        ]
                                                                    ],
                                                                    "GeneralInfo" => [
                                                                        "Date" => date("Y-m-d"),
                                                                        "MeetingDateTime" => gmdate("Y-m-d\\TH:i:s\\Z"),
                                                                        "VisitTime" => 52,
                                                                        "WalkIn" => false,
                                                                        "VisitedUnitRecordTypeDeveloperName" => "Default",
                                                                        "Building" => "Bâtiment A",
                                                                        "UnitsSeeked" => ["3.5", "4.5", "Studio"],
                                                                        "Comment" => "Visite test (UI) - création Visit__c + Visited_Unit__c",
                                                                        "VisitedUnits" => [
                                                                            [
                                                                                "Unit" => "203",
                                                                                "ClientInterest" => "High",
                                                                                "TypeDeVisite" => "Visite",
                                                                                "Notes" => "Beau plan, bon prix.",
                                                                                "Comments" => "<p>Client très intéressé.</p>",
                                                                                "PositiveAspects" => ["Luminosité", "Vue"],
                                                                                "NegativeAspects" => ["Bruit"]
                                                                            ],
                                                                            [
                                                                                "Unit" => "305",
                                                                                "ClientInterest" => "Medium",
                                                                                "TypeDeVisite" => "Visite",
                                                                                "Notes" => "À revoir avec conjoint."
                                                                            ]
                                                                        ]
                                                                    ]
                                                                ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), ENT_QUOTES);
                                                                ?></textarea>

                    <div class="small">
                        Astuce : si Salesforce renvoie une erreur, tu la verras à droite.
                        Tu peux aussi faire <span class="kbd">CTRL/⌘ + A</span> puis <span class="kbd">CTRL/⌘ + V</span> pour coller ton JSON.
                    </div>
                </div>
            </div>

            <div class="card">
                <div class="card-h">
                    <strong>Résultat</strong>
                    <span class="pill" id="resultPill">—</span>
                </div>
                <div class="card-b">
                    <pre id="result">{ "info": "Aucun envoi effectué." }</pre>
                    <div class="small">
                        <span class="kbd">HTTP</span> affiche le status + le temps de réponse.
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        const el = (id) => document.getElementById(id);

        function setStatus(text, kind) {
            const s = el('status');
            const span = s.querySelector('span:last-child');
            span.textContent = text;
            s.className = 'status grow' + (kind ? (' ' + kind) : '');
        }

        function setHttpInfo(text) {
            el('httpInfo').textContent = text || 'HTTP —';
            el('resultPill').textContent = text || '—';
        }

        function formatJSON() {
            const ta = el('jsonInput');
            try {
                ta.value = JSON.stringify(JSON.parse(ta.value), null, 2);
                setStatus('JSON formaté.', 'ok');
            } catch (e) {
                setStatus('JSON invalide: ' + e.message, 'bad');
            }
        }

        function loadExample() {
            location.reload();
        }

        async function sendJSON() {
            const btn = el('sendBtn');
            btn.disabled = true;
            setStatus('Envoi en cours…', 'warn');
            setHttpInfo('HTTP …');

            const body = el('jsonInput').value;

            try {
                JSON.parse(body);
            } catch (e) {
                el('result').textContent = JSON.stringify({
                    ok: false,
                    error: "JSON invalide",
                    details: e.message
                }, null, 2);
                setStatus('JSON invalide: ' + e.message, 'bad');
                setHttpInfo('HTTP —');
                btn.disabled = false;
                return;
            }

            const t0 = performance.now();
            try {
                const res = await fetch('?action=create_all', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body
                });

                const txt = await res.text();
                const ms = Math.round(performance.now() - t0);

                setHttpInfo(`HTTP ${res.status} • ${ms} ms`);

                try {
                    const parsed = JSON.parse(txt);
                    el('result').textContent = JSON.stringify(parsed, null, 2);
                    if (parsed && parsed.ok) setStatus('Succès ✅', 'ok');
                    else setStatus('Réponse reçue (ok=false)', 'bad');
                } catch {
                    el('result').textContent = txt;
                    setStatus('Réponse non-JSON reçue.', 'bad');
                }

            } catch (err) {
                el('result').textContent = JSON.stringify({
                    ok: false,
                    error: String(err)
                }, null, 2);
                setStatus('Erreur réseau: ' + err.message, 'bad');
                setHttpInfo('HTTP —');
            } finally {
                btn.disabled = false;
            }
        }
    </script>
</body>

</html>