<?php

// Suprimir warnings de deprecation do PHP 8.2+ (MercadoPago SDK)
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');

// CORS - Origens permitidas (produção e desenvolvimento)
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Dominios de producao vem da env var ALLOWED_ORIGINS (separados por virgula),
// definida por cliente no servidor (ex: env[ALLOWED_ORIGINS] no PHP-FPM www.conf
// ou no config/config.php). NUNCA hardcodar dominio de cliente no codigo-mestre.
$allowedOrigins = [
    // Desenvolvimento local (sempre liberado)
    'http://localhost:3032',
    'http://localhost:3033',
    'http://localhost:3034',
    'http://localhost:3035',
];
// Adiciona os dominios de producao do cliente (env var ou constante do config.php)
$prodOriginsRaw = getenv('ALLOWED_ORIGINS');
// Fallback: constante ALLOWED_ORIGINS do config.php. Carrega o config aqui
// (require_once) pois o CORS roda ANTES do require principal la embaixo.
if (empty($prodOriginsRaw) && !defined('ALLOWED_ORIGINS')) {
    require_once __DIR__ . '/config/config.php';
}
if (empty($prodOriginsRaw) && defined('ALLOWED_ORIGINS')) {
    $prodOriginsRaw = ALLOWED_ORIGINS;
}
if (!empty($prodOriginsRaw)) {
    foreach (explode(',', $prodOriginsRaw) as $o) {
        $o = trim($o);
        if ($o !== '') $allowedOrigins[] = $o;
    }
}

if (in_array($origin, $allowedOrigins)) {
    header("Access-Control-Allow-Origin: $origin");
    header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
    header("Access-Control-Allow-Headers: Content-Type, Authorization, X-User-Phone, X-User-Cpf");
    header("Access-Control-Allow-Credentials: true");
}

// Preflight request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit();
}

require_once __DIR__ . '/config/config.php';
require __DIR__ . '/helpers/phone_helper.php';
require __DIR__ . '/helpers/security_helper.php';
require __DIR__ . '/helpers/response_helper.php';
require __DIR__ . '/helpers/request_helper.php';
require __DIR__ . '/helpers/order_status.php';
require __DIR__ . '/helpers/jwt_helper.php';
require __DIR__ . '/helpers/totp_helper.php';
require __DIR__ . '/helpers/datetime_helper.php';
require __DIR__ . '/helpers/ranking_helper.php';
require __DIR__ . '/helpers/bool_normalize.php';
require __DIR__ . '/helpers/gamification_types.php'; // GAMIFICATION_TYPES + gamificationTypeMeta (fonte única dos 4 tipos)
require __DIR__ . '/helpers/double_chance_helper.php';
require __DIR__ . '/helpers/combo_helper.php';
require __DIR__ . '/helpers/coupon_helper.php';
require __DIR__ . '/helpers/upsell_helper.php';
require __DIR__ . '/helpers/revalidate_helper.php';
require_once __DIR__ . '/gateways/gateway_helper.php'; // isPaidStatus/consultPaymentStatus disponíveis globalmente (usados nos webhooks)

// Cloudflare Turnstile — o secret vem do config.php (define TURNSTILE_SECRET_KEY),
// NUNCA hardcoded aqui (era uma chave em claro no repositório — achado de segurança).
// Se não configurado, a validação é pulada (não bloqueia vendas) — mesmo fallback de erro.
function validateTurnstileToken($token) {
    if (!defined('TURNSTILE_SECRET_KEY') || TURNSTILE_SECRET_KEY === '') {
        return true; // Turnstile não configurado neste cliente → não bloqueia
    }
    $response = @file_get_contents('https://challenges.cloudflare.com/turnstile/v0/siteverify', false,
        stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => 'Content-Type: application/x-www-form-urlencoded',
                'content' => http_build_query([
                    'secret' => TURNSTILE_SECRET_KEY,
                    'response' => $token,
                ]),
                'timeout' => 5,
            ],
        ])
    );
    if ($response === false) return true; // Se API falhar, permite (nao bloqueia vendas)
    $result = json_decode($response, true);
    return !empty($result['success']) && $result['success'] === true;
}

// Quantidade máxima dinâmica — calcula variação determinística baseada no tempo
function calculateDynamicQtdMax($qtdMax, $interval, $campaignId, $qtdMin = 1) {
    if ($interval <= 0) return $qtdMax;
    $seed = floor(time() / ($interval * 60));
    $hash = abs(crc32($campaignId . '_' . $seed)) % 100;
    $factor = $hash / 100;
    return max($qtdMin, (int) round($qtdMin + ($qtdMax - $qtdMin) * $factor));
}

// Ativar handler de erros para errors.log
setupErrorHandler();

// ===========================================
// FUNÇÕES AUXILIARES DE SEGURANÇA (APIs públicas)
// ===========================================

/**
 * Retorna primeiro nome + inicial do último
 * "Genivaldo Gomes Ferreira" → "Genivaldo F..."
 * "Ivanilson Costa" → "Ivanilson C..."
 * "Maria" → "Maria"
 */
if (!function_exists('formatFirstName')) {
    function formatFirstName($fullName) {
        if (empty($fullName)) return null;
        $parts = explode(' ', trim($fullName));
        return ucfirst(strtolower($parts[0]));
    }
}

// Nota: maskPhone() já existe em security_helper.php - reutilizamos ela

/**
 * Lê uma flag booleana de um horário público (public_schedules), à prova das duas formas.
 *
 * ⚠️ Este PDO devolve bool de verdade, mas instalação com emulate_prepares ligado devolve
 * 't'/'f'. NÃO usar filter_var(FILTER_VALIDATE_BOOLEAN) aqui: ele NÃO reconhece 't'
 * (devolve false, silenciosamente). O `?? false` cobre cliente que ainda não rodou a migration.
 */
function scheduleFlagOn($schedule, $flag) {
    return in_array($schedule[$flag] ?? false, [true, 1, '1', 't', 'true', 'on', 'yes'], true);
}

/**
 * Nome do comprador para exibição pública, conforme a opção "Mostrar nome completo"
 * do horário (public_schedules.show_full_name, migration 056).
 *
 * Desligado (padrão) → só o primeiro nome, a máscara única do site (formatFirstName).
 * Ligado             → nome completo. É dado LGPD, então é escolha consciente do admin.
 *
 * Existe pra a decisão morar num lugar só: os 3 pontos do público (destaque, listagem e
 * payload da campanha) montam o nome, e sem isto o mesmo ternário viveria triplicado —
 * que é como um deles fica para trás numa mudança.
 */
function schedulePublicName($schedule, $rawName) {
    return scheduleFlagOn($schedule, 'show_full_name')
        ? trim($rawName ?? '')
        : formatFirstName($rawName ?? '');
}

/**
 * Busca o maior/menor título de um horário público, respeitando a opção
 * "Permitir repetir o comprador" (public_schedules.allow_repeat_buyer).
 *
 *   desligado → 1 linha por PEDIDO  (João com os títulos 1, 3 e 4 aparece 1x, título 1)
 *   ligado    → 1 linha por TÍTULO  (o mesmo João aparece 3x: 1, 3 e 4)
 *
 * Existe pra a decisão morar em UM lugar: o painel e os 3 pontos do site público
 * (destaque, listagem e payload da campanha) precisam concordar, senão o cliente vê
 * uma lista no painel e o comprador vê outra pro mesmo horário.
 *
 * ⚠️ O boolean é lido à prova das duas formas: este PDO devolve bool de verdade, mas
 * instalação com emulate_prepares ligado pode devolver 't'/'f'. NÃO use
 * filter_var(FILTER_VALIDATE_BOOLEAN) aqui: ele NÃO reconhece 't' (devolve false).
 * O `?? false` cobre cliente que ainda não rodou a migration 052.
 */
function fetchTopBiggerSmallerForSchedule($orderModel, $schedule, $campaignId, $type, $limit) {
    $byTitle = scheduleFlagOn($schedule, 'allow_repeat_buyer');

    $method = $byTitle ? 'getTopBiggerSmallerByTitle' : 'getTopBiggerSmaller';

    // "Quantidade mínima de títulos" gravada NO HORÁRIO (migration 054). 1 = sem filtro.
    // Antes o filtro vivia só no Buscar do painel: o admin buscava com mínimo 20, via a
    // lista certa, salvava — e o site público mostrava outra, porque lá o mínimo era 1.
    $minTitles = max(1, (int)($schedule['min_titles'] ?? 1));

    return $orderModel->$method($campaignId, $schedule['start_date'], $schedule['end_date'], $type, $limit, $minTitles);
}

/**
 * Cache em ARQUIVO dos rankings de Maior/Menor Título (endpoints /bigger-smaller e
 * /highlighted-schedule). Mesma mecânica do top-buyers (pasta cache/ranking, JSON), mas com
 * duração PRÓPRIA: settings.bigger_smaller_cache_minutes (Config › Experiência do Usuário),
 * separada do ranking_cache_minutes. '0' = tempo real (sem cache).
 *
 * A query já foi otimizada (28s → ~40ms na reescrita de getTopBiggerSmallerByTitle), então o
 * cache aqui é alívio extra, não salva-vidas — por isso é simples (sem flock): um "stampede"
 * de queries de 40ms é inofensivo.
 *
 * @return array{0:string,1:int} [caminho do arquivo de cache, minutos de validade]
 */
function biggerSmallerCache($pdo, $key) {
    require_once __DIR__ . '/models/Settings.php';
    $settingsModel = new \models\Settings($pdo);
    $minutes = (int)($settingsModel->getSettings()['bigger_smaller_cache_minutes'] ?? 5);
    $cacheDir = __DIR__ . '/cache/ranking';
    if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0755, true); }
    // Chave sanitizada (slug pode vir do usuário) pra não escapar do diretório.
    $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', (string)$key);
    return [$cacheDir . '/' . $safe . '.json', $minutes];
}

/** Lê o cache se existir e estiver dentro da validade; senão null. */
function biggerSmallerCacheGet($cacheFile, $minutes) {
    if ($minutes <= 0 || !is_file($cacheFile)) { return null; }
    if ((time() - filemtime($cacheFile)) >= $minutes * 60) { return null; }
    $data = json_decode((string)@file_get_contents($cacheFile), true);
    return $data === null ? null : $data;
}

/** Grava o cache (só se a duração > 0). Falha de escrita é silenciosa — não quebra a resposta. */
function biggerSmallerCachePut($cacheFile, $minutes, $data) {
    if ($minutes > 0) { @file_put_contents($cacheFile, json_encode($data, JSON_UNESCAPED_UNICODE)); }
}

/**
 * Monta os horários EM DESTAQUE de uma campanha (public_schedules.highlight = true), já com
 * os ganhadores resolvidos, no formato que o site público consome.
 *
 * Existe porque esse bloco vivia COPIADO em dois lugares — o endpoint /highlighted-schedule
 * e o SSR da campanha (campaign.highlighted_schedule) — ~40 linhas iguais, cada um com seu
 * LIMIT 1. Quando o destaque deixou de ser exclusivo (15/09/2026: o cliente pode destacar
 * quantos horários quiser), a mudança teria que ser feita duas vezes. Agora é uma.
 *
 * Ordem: por data de término — o que vence primeiro em cima, que é o que mais urge.
 *
 * Cada ganhador traz, além de nome e número, os dados da compra que o site público exibe
 * no card do líder (página da compra): telefone mascarado, quantidade e data. O valor
 * gasto NÃO vai de propósito — é dado de outra pessoa num link público; decisão de
 * privacidade tomada em 15/09/2026.
 *
 * @param int  $qtdDigits  dígitos do título (strlen de qtd_numbers-1), pro zero à esquerda
 * @param bool $onlyHighlighted  true = só os em destaque (página da campanha);
 *                                false = todas as janelas, encerradas inclusive (página da
 *                                compra, que mostra o líder de cada uma, destacada ou não)
 * @return array  lista (pode ser vazia); cada item tem type/title/start_date/end_date/prize/
 *                quantity/winners e `winner` (singular, o 1º) — mantido pro front antigo.
 */
function buildHighlightedSchedules($pdo, $campaignId, $qtdDigits, $onlyHighlighted = true) {
    // Compra: as janelas com "Mostrar na compra" ligado (migration 061), inclusive as
    // encerradas (o countdown mostra "Encerrado" e o card vira o resultado final — igual ao
    // destaque na página da campanha). Cliente sem a 061 cai no try/catch do endpoint do
    // pedido (lista vazia) — a página da campanha não é afetada, ela usa o outro ramo.
    $where = $onlyHighlighted ? "highlight = true" : "COALESCE(show_on_order, false) = true";
    $stmt = $pdo->prepare("SELECT id, type, start_date, end_date, prize, quantity, allow_repeat_buyer, min_titles, show_full_name, show_spent FROM public_schedules WHERE campaign_id = ? AND {$where} ORDER BY end_date ASC, id ASC");
    $stmt->execute([$campaignId]);
    $schedules = $stmt->fetchAll(PDO::FETCH_ASSOC);
    if (!$schedules) {
        return [];
    }

    require_once __DIR__ . '/models/Order.php';
    $orderModel = new \models\Order($pdo);

    $list = [];
    foreach ($schedules as $schedule) {
        $type = $schedule['type'];
        // Respeita a QUANTIDADE do horário (antes era fixo em 1, só o recordista).
        $qty = max(1, (int)($schedule['quantity'] ?? 1));
        $orders = fetchTopBiggerSmallerForSchedule($orderModel, $schedule, $campaignId, $type, $qty);

        // Valor da compra: opcional por horário (show_spent, reintroduzido na 104). O destacado
        // ficava SEMPRE mostrando o valor (herança da 070 que tinha removido o toggle); agora
        // respeita o show_spent igual o modal/dialog faz. String-boolean do PG via isTrueish.
        $showSpent = isTrueish($schedule['show_spent'] ?? false);

        $winners = [];
        foreach ($orders as $idx => $order) {
            $winner = [
                'position' => $idx + 1,
                // Máscara única em todo o site: só o primeiro nome (ver schedulePublicName).
                'user_name' => schedulePublicName($schedule, $order['user_name'] ?? ''),
                'title_number' => str_pad($order['title'], $qtdDigits, '0', STR_PAD_LEFT),
                'phone_masked' => maskPhone($order['user_phone'] ?? ''),
                'titles_count' => (int)($order['titles_count'] ?? 0),
                'created_at' => isoFromUtc($order['created_at'] ?? null),
            ];
            // Só manda o total quando o horário tem "Mostrar valor gasto" ligado.
            if ($showSpent) {
                $winner['total'] = (float)($order['total'] ?? 0);
            }
            $winners[] = $winner;
        }

        $list[] = [
            'id' => (int)$schedule['id'],
            'type' => $type,
            'title' => $type === 'min' ? 'Menor Título' : 'Maior Título',
            'start_date' => $schedule['start_date'],
            'end_date' => $schedule['end_date'],
            'prize' => $schedule['prize'] ?? '',
            'quantity' => $qty,
            'winners' => $winners,
            // `winner` (singular) mantido: front público antigo, ainda não rebuildado, lê só ele.
            'winner' => $winners[0] ?? null,
        ];
    }

    return $list;
}

/**
 * Carrega combos, ganhadores e títulos premiados de uma campanha para API pública
 * Usado nos endpoints de campanha por slug e por hash_id
 */
function loadCombosWinnersAndTitles($pdo, &$campaign, $campaignModel) {
    $campaignId = $campaign['id'];

    // Boolean: normaliza as flags de feature ANTES de usá-las. O Postgres devolve 't'/'f'
    // (string) e `!empty('f')` é true → sem isto o backend buscava combos/winners mesmo com a
    // feature DESLIGADA (query à toa). filter_var trata 't'/'f'/1/0/true/false.
    foreach (GAMIFICATION_TYPES as $__t) {
        foreach (["{$__t}_enabled", "{$__t}_show_winners"] as $flag) {
            if (isset($campaign[$flag])) {
                $campaign[$flag] = filter_var($campaign[$flag], FILTER_VALIDATE_BOOLEAN);
            }
        }
    }

    // Combos + ganhadores das 4 dinâmicas (box/roulette/scratch/kick). Antes eram 8 blocos copy-paste
    // (1 combos + 1 awards por tipo); agora 1 loop sobre GAMIFICATION_TYPES — mesma lógica, fonte única.
    // Awards: as 4 dinâmicas migraram para prize_titles (type={tipo}); lê de lá o shape que a
    // AwardsSection espera (prize + winner_name). winner_name vem do comprador (users via
    // delivered_user_id) só quando o título está 'entregue'; senão null = "Disponível".
    foreach (GAMIFICATION_TYPES as $__t) {
        $m = gamificationTypeMeta($__t);

        // Combos: só quando a dinâmica está habilitada.
        if ($campaign[$m['enabledFlag']] ?? false) {
            try {
                $stmt = $pdo->prepare("SELECT * FROM {$m['combosTable']} WHERE campaign_id = ? AND show_combo = true ORDER BY min_quantity ASC");
                $stmt->execute([$campaignId]);
                $campaign[$m['combosKey']] = array_map(function ($c) { normalizeBooleans($c, COMBO_BOOL_FIELDS); return $c; }, $stmt->fetchAll(PDO::FETCH_ASSOC));
            } catch (Exception $e) { $campaign[$m['combosKey']] = []; }
        }

        // Ganhadores: só quando habilitada E "Exibir ganhadores" ligada.
        if (!empty($campaign[$m['enabledFlag']]) && !empty($campaign[$m['showWinnersFlag']])) {
            try {
                $stmt = $pdo->prepare("
                    SELECT pt.prize, CASE WHEN pt.status = 'entregue' THEN u.name ELSE NULL END AS winner_name
                    FROM prize_titles pt
                    LEFT JOIN users u ON u.id = pt.delivered_user_id
                    WHERE pt.campaign_id = ? AND pt.type = ?
                    ORDER BY CASE WHEN pt.status = 'entregue' THEN 0 ELSE 1 END, pt.id DESC
                ");
                $stmt->execute([$campaignId, $__t]);
                $campaign[$m['awardsKey']] = $stmt->fetchAll(PDO::FETCH_ASSOC);
            } catch (Exception $e) { $campaign[$m['awardsKey']] = []; }
        }
    }

    // Títulos premiados
    try {
        $prizeTitles = $campaignModel->getPrizeTitlesForDisplay($campaignId);
        if (!empty($prizeTitles)) {
            foreach ($prizeTitles as &$pt) {
                if (!empty($pt['buyer_name'])) {
                    $pt['buyer_name'] = formatFirstName($pt['buyer_name']);
                }
                if (isset($pt['status']) && $pt['status'] === 'bloqueado') {
                    $pt['status'] = 'disponivel';
                }
            }
            unset($pt);
            $campaign['prize_titles'] = $prizeTitles;

            $buyers = $campaignModel->getBuyersOfDrawTitles($campaignId);
            foreach ($buyers as $number => $name) {
                $buyers[$number] = formatFirstName($name);
            }
            $campaign['buyers_of_draw_titles'] = $buyers;
        }
    } catch (Exception $e) {
        $campaign['prize_titles'] = [];
        $campaign['buyers_of_draw_titles'] = [];
    }

    // Remover campaign_id de combos e awards (não expor ID numérico) + normalizar "Exibir Premiação".
    // isTrueish (não (bool)) no show_prize: o PG devolve boolean cru como 'f' (string) e (bool)'f' === true,
    // o que mostraria o prêmio mesmo com "Exibir Premiação" DESLIGADA (vazamento). Roda antes do normalizeBooleans.
    foreach (GAMIFICATION_TYPES as $__t) {
        $m = gamificationTypeMeta($__t);
        foreach ([$m['combosKey'], $m['awardsKey']] as $key) {
            if (!empty($campaign[$key])) {
                foreach ($campaign[$key] as &$item) {
                    unset($item['campaign_id']);
                }
                unset($item);
            }
        }
        $campaign[$m['showPrizeFlag']] = isTrueish($campaign[$m['showPrizeFlag']] ?? false);
    }
}

// ===========================================
// FUNÇÕES DE VALIDAÇÃO DE DADOS
// ===========================================

/**
 * Valida telefone brasileiro
 * - Deve ter 10 ou 11 dígitos (DDD + número)
 * - DDD deve ser válido (11-99, exceto alguns inválidos)
 * - Não pode ser todos os dígitos iguais
 *
 * @param string $phone Telefone (com ou sem formatação)
 * @return array ['valid' => bool, 'message' => string, 'cleaned' => string]
 */
function validatePhone($phone, $country = 'BR') {
    // Só dígitos e sem DDI — a regra mora no helper porque os importadores (CLI) precisam dela.
    $phoneClean = normalizePhoneDigits($phone, $country);

    // Verificar se não é vazio ou só zeros
    if (empty($phoneClean) || preg_match('/^0+$/', $phoneClean)) {
        return [
            'valid' => false,
            'message' => 'Telefone inválido',
            'cleaned' => $phoneClean
        ];
    }

    // Verificar se não são todos dígitos iguais
    if (preg_match('/^(\d)\1+$/', $phoneClean)) {
        return [
            'valid' => false,
            'message' => 'Telefone inválido',
            'cleaned' => $phoneClean
        ];
    }

    // Validação específica por país
    switch ($country) {
        case 'BR':
            // Brasil: 10-11 dígitos, DDD válido
            if (strlen($phoneClean) < 10 || strlen($phoneClean) > 11) {
                return [
                    'valid' => false,
                    'message' => 'Telefone deve ter 10 ou 11 dígitos',
                    'cleaned' => $phoneClean
                ];
            }
            // Validar DDD brasileiro
            $ddd = substr($phoneClean, 0, 2);
            $invalidDdds = ['00','01','02','03','04','05','06','07','08','09','10','20','23','25','26','29','30','36','39','40','50','52','56','57','58','59','60','70','72','76','78','80','90'];
            if (in_array($ddd, $invalidDdds)) {
                return [
                    'valid' => false,
                    'message' => 'DDD inválido',
                    'cleaned' => $phoneClean
                ];
            }
            break;

        case 'US':
        case 'CA':
            // EUA/Canadá: 10 dígitos
            if (strlen($phoneClean) !== 10) {
                return [
                    'valid' => false,
                    'message' => 'Telefone deve ter 10 dígitos',
                    'cleaned' => $phoneClean
                ];
            }
            break;

        case 'PT':
            // Portugal: 9 dígitos
            if (strlen($phoneClean) !== 9) {
                return [
                    'valid' => false,
                    'message' => 'Telefone deve ter 9 dígitos',
                    'cleaned' => $phoneClean
                ];
            }
            break;

        case 'AR':
        case 'CL':
        case 'CO':
        case 'PE':
        case 'MX':
            // América Latina: 9-11 dígitos
            if (strlen($phoneClean) < 9 || strlen($phoneClean) > 11) {
                return [
                    'valid' => false,
                    'message' => 'Telefone deve ter 9 a 11 dígitos',
                    'cleaned' => $phoneClean
                ];
            }
            break;

        default:
            // Outros países: aceitar 7-15 dígitos (padrão internacional)
            if (strlen($phoneClean) < 7 || strlen($phoneClean) > 15) {
                return [
                    'valid' => false,
                    'message' => 'Telefone inválido',
                    'cleaned' => $phoneClean
                ];
            }
    }

    return [
        'valid' => true,
        'message' => 'OK',
        'cleaned' => $phoneClean,
        'country' => $country
    ];
}

/**
 * Valida CPF brasileiro
 * - Deve ter 11 dígitos
 * - Não pode ser todos os dígitos iguais
 * - Dígitos verificadores devem ser válidos
 *
 * @param string $cpf CPF (com ou sem formatação)
 * @return array ['valid' => bool, 'message' => string, 'cleaned' => string]
 */
function validateCpf($cpf) {
    // Limpar CPF (apenas números)
    $cpfClean = preg_replace('/[^0-9]/', '', $cpf);

    // Verificar tamanho
    if (strlen($cpfClean) !== 11) {
        return [
            'valid' => false,
            'message' => 'CPF deve ter 11 dígitos',
            'cleaned' => $cpfClean
        ];
    }

    // Verificar se não são todos dígitos iguais
    if (preg_match('/^(\d)\1+$/', $cpfClean)) {
        return [
            'valid' => false,
            'message' => 'CPF inválido',
            'cleaned' => $cpfClean
        ];
    }

    // Calcular primeiro dígito verificador
    $soma = 0;
    for ($i = 0; $i < 9; $i++) {
        $soma += intval($cpfClean[$i]) * (10 - $i);
    }
    $resto = $soma % 11;
    $digito1 = ($resto < 2) ? 0 : (11 - $resto);

    if (intval($cpfClean[9]) !== $digito1) {
        return [
            'valid' => false,
            'message' => 'CPF inválido',
            'cleaned' => $cpfClean
        ];
    }

    // Calcular segundo dígito verificador
    $soma = 0;
    for ($i = 0; $i < 10; $i++) {
        $soma += intval($cpfClean[$i]) * (11 - $i);
    }
    $resto = $soma % 11;
    $digito2 = ($resto < 2) ? 0 : (11 - $resto);

    if (intval($cpfClean[10]) !== $digito2) {
        return [
            'valid' => false,
            'message' => 'CPF inválido',
            'cleaned' => $cpfClean
        ];
    }

    return [
        'valid' => true,
        'message' => 'OK',
        'cleaned' => $cpfClean
    ];
}

/**
 * Valida email
 *
 * @param string $email Email
 * @return array ['valid' => bool, 'message' => string]
 */
function validateEmail($email) {
    if (empty($email)) {
        return [
            'valid' => false,
            'message' => 'Email é obrigatório'
        ];
    }

    // Usar filter_var do PHP
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return [
            'valid' => false,
            'message' => 'Email inválido'
        ];
    }

    // Verificar se tem domínio válido (pelo menos um ponto após @)
    $parts = explode('@', $email);
    if (count($parts) !== 2 || strpos($parts[1], '.') === false) {
        return [
            'valid' => false,
            'message' => 'Email inválido'
        ];
    }

    return [
        'valid' => true,
        'message' => 'OK'
    ];
}

/**
 * Valida data de nascimento e idade mínima
 *
 * @param string $birthDate Data no formato DD/MM/AAAA
 * @param int $minAge Idade mínima (default 18)
 * @return array ['valid' => bool, 'message' => string, 'age' => int]
 */
function validateBirthDate($birthDate, $minAge = 18) {
    if (empty($birthDate)) {
        return [
            'valid' => false,
            'message' => 'Data de nascimento é obrigatória',
            'age' => 0
        ];
    }

    // Verificar formato DD/MM/AAAA
    if (!preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $birthDate)) {
        return [
            'valid' => false,
            'message' => 'Data deve estar no formato DD/MM/AAAA',
            'age' => 0
        ];
    }

    $parts = explode('/', $birthDate);
    $day = intval($parts[0]);
    $month = intval($parts[1]);
    $year = intval($parts[2]);

    // Verificar se data é válida
    if (!checkdate($month, $day, $year)) {
        return [
            'valid' => false,
            'message' => 'Data de nascimento inválida',
            'age' => 0
        ];
    }

    // Calcular idade
    try {
        $birthDateObj = new DateTime("$year-$month-$day");
        $today = new DateTime();

        // Verificar se data não é futura
        if ($birthDateObj > $today) {
            return [
                'valid' => false,
                'message' => 'Data de nascimento não pode ser futura',
                'age' => 0
            ];
        }

        $age = $today->diff($birthDateObj)->y;

        // Verificar idade mínima
        if ($age < $minAge) {
            return [
                'valid' => false,
                'message' => "Idade mínima é $minAge anos",
                'age' => $age
            ];
        }

        // Verificar idade máxima razoável (150 anos)
        if ($age > 150) {
            return [
                'valid' => false,
                'message' => 'Data de nascimento inválida',
                'age' => $age
            ];
        }

        return [
            'valid' => true,
            'message' => 'OK',
            'age' => $age
        ];
    } catch (Exception $e) {
        return [
            'valid' => false,
            'message' => 'Data de nascimento inválida',
            'age' => 0
        ];
    }
}

/**
 * Valida quantidade no checkout
 *
 * @param mixed $quantity Quantidade
 * @param int $min Mínimo permitido
 * @param int $max Máximo permitido
 * @return array ['valid' => bool, 'message' => string, 'value' => int]
 */
function validateQuantity($quantity, $min = 1, $max = 1000) {
    // Verificar se é número
    if (!is_numeric($quantity)) {
        return [
            'valid' => false,
            'message' => 'Quantidade deve ser um número',
            'value' => 0
        ];
    }

    $qty = intval($quantity);

    // Verificar se é inteiro positivo
    if ($qty != $quantity || $qty <= 0) {
        return [
            'valid' => false,
            'message' => 'Quantidade deve ser um número inteiro positivo',
            'value' => $qty
        ];
    }

    // Verificar mínimo
    if ($qty < $min) {
        return [
            'valid' => false,
            'message' => "Quantidade mínima é $min",
            'value' => $qty
        ];
    }

    // Verificar máximo
    if ($qty > $max) {
        return [
            'valid' => false,
            'message' => "Quantidade máxima é $max",
            'value' => $qty
        ];
    }

    return [
        'valid' => true,
        'message' => 'OK',
        'value' => $qty
    ];
}

/**
 * Remove campos sensíveis de uma campanha para APIs públicas
 */
function removeSensitiveFields(&$campaign) {
    $sensitiveFields = [
        // Credenciais de pagamento (CRÍTICO)
        'suitpay_client_id',
        'suitpay_client_secret',
        'efi_client_id',
        'efi_client_secret',
        'efi_pix_key',
        'efi_pix_cert',
        'pay2m_client_id',
        'pay2m_client_secret',
        // MagenPay (VAZAMENTO CRÍTICO corrigido — estavam faltando: a chave privada do
        // gateway vazava publicamente em /api/public/campaign/{slug}). Ver
        // docs/ESTUDO_CONCORRENTE_E_PLANO_SUPERAR.md §4.
        'magenpay_public_key_id',
        'magenpay_private_key',
        'magenpay_pix_key',
        'gateway_merchant_id',
        'gateway_access_token',
        // Mercado Pago — access token e webhook secret NÃO podem vazar publicamente.
        'mp_access_token',
        'mp_webhook_secret',
        // Asaas — API key e webhook token NÃO podem vazar publicamente.
        'asaas_api_key',
        'asaas_webhook_token',
        // AVEX — as chaves NÃO podem vazar publicamente (a secret_key faz SAQUE na conta).
        'avex_public_key',
        'avex_secret_key',

        // Flags de gateway (config interna)
        'gateway_efi_bank',
        'gateway_pay2m',
        'gateway_suitpay',
        'gateway_magenpay',
        'gateway_mercado_pago',

        // Config anti-spam (não expor lógica)
        'antispam_active',
        'antispam_max_unpaid',
        'antispam_block_time',

        // Probabilidades (lógica de negócio)
        'probability',
        'probability_box',
        'probability_scratch_card',

        // ID numérico (expor apenas hash_id)
        'id',

        // Campos legados não utilizados
        'numbers_spin',
        'spins',
        'awards_available_spin',
        'awards_won_spin',
        'numbers_box',
        'boxes',
        'awards_available_box',
        'awards_won_box',
        'step_scratch_card',
        'award_scratch_card',
        'award_won_scratch_card',
        'count_scratch_card',

        // CRÍTICO: Expõe números premiados - NUNCA mostrar publicamente
        'custom_titles',

        // Dados de vendas (NUNCA expor publicamente)
        'pending_numbers',
        'sold_numbers',
        'total_numbers_sold',

        // CRÍTICO: porcentagem manipulada de vendas — revela que a barra e fabricada
        'percentage_applied',
        'percentage_applied_active',

        // Config de capitalizadora (interna). capitalizer_code fica (o site usa).
        'capitalizer',
        'capitalizer_active',

        // Config de afiliados (interna)
        'affiliates',
        'restrict_affiliates',

        // Config interna de negocio que o site publico NAO usa
        'qtd_max_buyer',
        'numbers_generated',
        'add_fee',
        'auto_close_on_draw',
        'init_option',
        'scheduled_date',
        'updated_at',
    ];

    foreach ($sensitiveFields as $field) {
        unset($campaign[$field]);
    }

    // Mascarar telefone do ganhador no campo winner (suporta múltiplos ganhadores)
    if (!empty($campaign['winner'])) {
        // Separar múltiplos ganhadores: cada um está entre { }
        preg_match_all('/\{([^}]+)\}/', $campaign['winner'], $matches);
        if (!empty($matches[1])) {
            $maskedWinners = [];
            foreach ($matches[1] as $winnerStr) {
                $winnerValues = explode(',', $winnerStr);
                if (count($winnerValues) >= 3) {
                    $winnerValues[2] = ' ' . maskPhone(trim($winnerValues[2]));
                }
                $maskedWinners[] = '{' . implode(',', $winnerValues) . '}';
            }
            $campaign['winner'] = implode(', ', $maskedWinners);
        }
    }
}

/**
 * Mascara estatísticas de vendas na API pública
 * Usa o progress_bar_percent (ajustável) para calcular valores fake
 * Assim ninguém consegue descobrir os números reais
 */
function maskSalesStatistics(&$campaign, $isPublicApi = false) {
    // Verificar se tem percentual fixado
    $isAdjustable = $campaign['progress_bar_adjustable'] === true
        || $campaign['progress_bar_adjustable'] === 1
        || $campaign['progress_bar_adjustable'] === '1';

    $percentValue = $campaign['progress_bar_percent'] ?? '';
    $hasFixedPercent = $percentValue !== null && $percentValue !== '' && $percentValue !== 0 && $percentValue !== '0';

    // Se tem percentual fixado, usar ele
    if ($isAdjustable && $hasFixedPercent) {
        $campaign['percentage_sold'] = floatval(str_replace(',', '.', $percentValue));
    }

    // Se tem porcentagem aplicada, calcular proporcionalmente
    $isAppliedActive = $campaign['percentage_applied_active'] === true
        || $campaign['percentage_applied_active'] === 't'
        || $campaign['percentage_applied_active'] === 1
        || $campaign['percentage_applied_active'] === '1';

    if ($isAppliedActive && !$isAdjustable) {
        $appliedValue = floatval($campaign['percentage_applied'] ?? 0);
        $realPercent = floatval($campaign['percentage_sold'] ?? 0);
        $remaining = 100 - $appliedValue;
        $campaign['percentage_sold'] = min(100, $appliedValue + ($realPercent * $remaining / 100));
    }

    // Para API pública: remover campos que permitem calcular porcentagem real
    if ($isPublicApi) {
        // Para campanhas manuais, manter qtd_numbers pois é necessário para o grid de números
        // Mas remover os contadores de vendidos/disponíveis
        $isManualCampaign = ($campaign['type_raffle'] ?? '') === 'Manual';

        // TETO DE COMPRA pro seletor bater no que resta, SEM vazar o total vendido. É o menor entre
        // o qtd_max e os números que ainda restam. Só revela "resta pouco" quando o disponível fica
        // abaixo do qtd_max — não o número exato de vendas. Calculado ANTES dos unset abaixo.
        $availForPurchase = $campaign['available_numbers_count'] ?? null;
        if ($availForPurchase !== null) {
            $qtdMaxCamp = (int)($campaign['qtd_max'] ?? 0);
            $campaign['max_purchase'] = $qtdMaxCamp > 0
                ? min($qtdMaxCamp, (int)$availForPurchase)
                : (int)$availForPurchase;
        }

        unset($campaign['sold_numbers_count']);
        unset($campaign['available_numbers_count']);
        unset($campaign['sold_numbers']);
        unset($campaign['total_numbers_sold']);
        unset($campaign['pending_numbers']);

        // Remover qtd_numbers apenas para campanhas automáticas
        // (em manuais o grid precisa saber quantos números existem)
        if (!$isManualCampaign) {
            unset($campaign['qtd_numbers']);
        }

        // Remover campos de configuração que não devem ser públicos
        unset($campaign['progress_bar_adjustable']);
        unset($campaign['progress_bar_percent']);

        // Limpar campos sensíveis dos prêmios de jogos (roleta, raspadinha, caixa)
        maskAwardsForPublic($campaign);
    }
}

/**
 * Remove campos sensíveis dos arrays de prêmios para API pública
 * Campos removidos: exit_percent, winner_phone, is_paid, locked
 *
 * Segurança/LGPD:
 * - Mascara winner_name (só primeiro nome) de TODAS as modalidades (inclui kick).
 * - "Exibir Premiação": quando o *_show_prize da modalidade está DESATIVADO (false),
 *   o prêmio real NUNCA sai do servidor — o campo `prize` é substituído pelo nome
 *   genérico da modalidade. A decisão é 100% no backend (não confia no frontend).
 */
function maskAwardsForPublic(&$campaign) {
    // modalidade => [flag show_prize na campanha, nome genérico do prêmio]
    $awardTypes = [
        'roulette_awards' => ['roulette_show_prize', 'Ganhou Roleta Premiada'],
        'scratch_awards'  => ['scratch_show_prize',  'Ganhou Raspadinha Premiada'],
        'box_awards'      => ['box_show_prize',       'Ganhou Caixa Premiada'],
        'kick_awards'     => ['kick_show_prize',      'Ganhou Chute Premiado'],
    ];
    $sensitiveFields = ['exit_percent', 'winner_phone', 'is_paid', 'locked'];

    foreach ($awardTypes as $type => $config) {
        [$showPrizeField, $genericName] = $config;
        $showPrize = isTrueish($campaign[$showPrizeField] ?? false); // PG boolean cru: (bool)'f'===true vazaria o prêmio

        if (isset($campaign[$type]) && is_array($campaign[$type])) {
            foreach ($campaign[$type] as &$award) {
                foreach ($sensitiveFields as $field) {
                    unset($award[$field]);
                }
                // Mascarar nome do ganhador (só primeiro nome)
                if (!empty($award['winner_name'])) {
                    $award['winner_name'] = formatFirstName($award['winner_name']);
                }
                // Exibir Premiação DESATIVADO: nunca expor o prêmio real na resposta pública
                if (!$showPrize && array_key_exists('prize', $award)) {
                    $award['prize'] = $genericName;
                }
            }
            unset($award);
        }
    }
}

// Tabelas já existem no Supabase PostgreSQL

// Track page visits (only for public pages, not dashboard/api)
// Conta apenas 1 visita por IP por dia (visitante único diário)
$currentPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
$isSubdomainApi = (strpos($currentHost, 'api.') === 0);
$isDashboard = (strpos($currentPath, '/dashboard') === 0);
$isApi = (strpos($currentPath, '/api') === 0) || $isSubdomainApi;
$isWebhook = (strpos($currentPath, '/webhook') === 0);
$isAsset = preg_match('/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/i', $currentPath);
$isPublicApi = (strpos($currentPath, '/public/') === 0);
$isLocalDev = (strpos($currentHost, 'localhost') !== false || strpos($currentHost, '127.0.0.1') !== false);

// PERFORMANCE/ESCALA (ver docs/PLANO_OTIMIZACAO_API_PUBLICA_ESCALA.md): detecta as rotas
// públicas read-only (/api/public/* no domínio principal, /public/* no subdomínio api.*).
// Auditoria (17 rotas) confirmou que NENHUMA usa $_SESSION — então nessas rotas pulamos
// session_start() + a hidratação global de user/settings, que era o maior peso sob tráfego
// alto (cada visita abria sessão no disco + query de settings à toa). Login/checkout/dashboard
// seguem com sessão normal. As rotas de order autenticam por JWT/telefone/cpf (header), não sessão.
$isPublicApiRoute = (
    strpos($currentPath, '/api/public/') === 0
    || strpos($currentPath, '/public/') === 0
);

// CORS já tratado no início do arquivo (linhas 6-30)
// Removido handler duplicado que usava Origin: * e quebrava credentials

// Proxy para imagens - busca da VPS remota se não existir localmente
if (preg_match('#^/public/images/(.+)$#', $currentPath, $matches)) {
    $fileName = urldecode($matches[1]);

    // Segurança: bloquear path traversal
    if (strpos($fileName, '..') !== false || strpos($fileName, '/') !== false || strpos($fileName, '\\') !== false) {
        http_response_code(400);
        exit();
    }

    $localPath = __DIR__ . '/public/images/' . $fileName;

    // Se existe localmente, serve direto
    if (file_exists($localPath)) {
        $mimeType = mime_content_type($localPath);
        header('Content-Type: ' . $mimeType);
        header('Cache-Control: public, max-age=31536000');
        readfile($localPath);
        exit();
    }

    // Busca do Supabase Storage (usando config.php)
    $remoteUrl = $supabaseUrl . '/storage/v1/object/public/' . $supabaseBucket . '/' . $fileName;

    // Fetch da imagem da VPS remota
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $remoteUrl);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    curl_setopt($ch, CURLOPT_HEADER, true);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    curl_close($ch);

    if ($httpCode === 200) {
        $headers = substr($response, 0, $headerSize);
        $body = substr($response, $headerSize);

        // Extrair Content-Type dos headers
        $contentType = 'image/jpeg'; // default
        if (preg_match('/Content-Type:\s*([^\r\n]+)/i', $headers, $ctMatch)) {
            $contentType = trim($ctMatch[1]);
        }

        header('Content-Type: ' . $contentType);
        header('Cache-Control: public, max-age=31536000');
        header('X-Proxy-Source: vps-remote');
        echo $body;
    } else {
        // Imagem não encontrada - retorna placeholder ou 404
        http_response_code(404);
        header('Content-Type: text/plain');
        echo 'Image not found';
    }
    exit();
}

// [REMOVIDO] Gravador de visitas server-side legado (do tempo do site em PHP).
// O site atual e Next.js e conta visitas via /api/public/track-visit (visit_daily).
// Este caminho HTML-via-PHP nao roda mais.

// Colunas já existem no schema PostgreSQL do Supabase

// Migrações automáticas removidas - schema PostgreSQL já inclui todas as colunas e tabelas

require __DIR__ . '/models/Settings.php';
require __DIR__ . '/models/Faq.php';
require __DIR__ . '/controllers/SettingsController.php';

use controller\SettingsController;

$settingsController = new SettingsController($pdo);
$settings = $settingsController->getSettings();

// Helper para garantir valores padrão do settings
require __DIR__ . '/helpers/settings_helper.php';

// Helper de geolocalização (IP + localização)
require __DIR__ . '/helpers/geolocation_helper.php';

// Rate limiting global: 200 req/min por IP (protege contra DDoS/abuse em qualquer rota)
// Nao aplica em assets estaticos nem webhooks de gateway (tem rate limit proprio)
if (!$isAsset && !$isWebhook) {
    if (!checkRateLimit($pdo, 'global', 200, 60)) {
        securityLog('rate_limit_global', ['path' => $currentPath]);
        respondRateLimitExceeded();
    }
}

// ESCALA: rotas públicas read-only NÃO abrem sessão (auditadas: nenhuma usa $_SESSION).
// Isso elimina a criação de sessão no disco + churn de PHPSESSID a cada hit público.
if (!$isPublicApiRoute && session_status() == PHP_SESSION_NONE) {
    // Configurar cookie de sessão para funcionar entre subdomínios
    $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
    // Extrair domínio raiz (ex: api.sortemaxima.io -> .sortemaxima.io)
    $parts = explode('.', $host);
    $cookieDomain = count($parts) >= 2
        ? '.' . implode('.', array_slice($parts, -2))
        : $host;
    $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';

    session_set_cookie_params([
        'lifetime' => 86400, // 24 horas
        'path' => '/',
        'domain' => $cookieDomain, // Permite compartilhar entre subdomínios
        'secure' => $isSecure, // HTTPS se disponível
        'httponly' => true, // Não acessível via JavaScript
        'samesite' => $isSecure ? 'None' : 'Lax' // None requer HTTPS
    ]);
    session_start();
}

require __DIR__ . '/controllers/CampaignController.php';
require __DIR__ . '/models/Campaign.php';
require __DIR__ . '/controllers/PurchaseController.php';
require __DIR__ . '/models/Order.php';
require __DIR__ . '/models/User.php';
require __DIR__ . '/models/TermsOfUse.php';
require __DIR__ . '/controllers/OrdersController.php';
require __DIR__ . '/middlewares/AuthMiddleware.php';
require __DIR__ . '/controllers/DashboardController.php';
require __DIR__ . '/models/Statistics.php';
require __DIR__ . '/models/Ranking.php';
require __DIR__ . '/controllers/RankingController.php';
require __DIR__ . '/controllers/AffiliateController.php';
require_once __DIR__ . '/models/Affiliate.php';
require_once __DIR__ . '/models/AffiliateSale.php';
require_once __DIR__ . '/models/Webhook.php';
require __DIR__ . '/controllers/ContactController.php';
require_once __DIR__ . '/services/OrderPaymentService.php';
require_once __DIR__ . '/models/Contact.php';
require_once __DIR__ . '/models/NumberService.php';
require_once __DIR__ . '/models/NumberGenerator.php';
require_once __DIR__ . '/models/Utm.php';

/**
 * Factory do motor de geração de números (campanhas Automáticas).
 *
 * Motor ÚNICO: NumberGenerator (busca-livres-direto). Substituiu o NumberEngine (cursor+LCG,
 * removido em 2026-11-05 — era a fonte dos bugs de reta final: pagou-sem-número, reciclado
 * inalcançável, overbooking por pending vazado). Não há mais distinção 'engine' vs 'legacy':
 * o motor novo olha o pool real a cada pagamento, então qualquer campanha Automática (nova ou
 * antiga) gera corretamente por ele, sem depender de cursor/LCG. O parâmetro $campaign é
 * mantido na assinatura por compatibilidade com os chamadores.
 */
function makeNumberEngine($campaign, $pdo) {
    return new \models\NumberGenerator($pdo);
}
require_once __DIR__ . '/gateways/functions/suitpay.php';
use controller\CampaignController;
use controller\PurchaseController;
use controller\OrdersController;
use middlewares\AuthMiddleware;
use controller\DashboardController;
use controller\RankingController;
use controller\AffiliateController;
use controller\ContactController;

use models\Order;

$campaignController = new CampaignController($pdo);
$campaignModel = new \models\Campaign($pdo);
$purchaseController = new PurchaseController($pdo);
$ordersController = new OrdersController($pdo);
$authMiddleware = new AuthMiddleware($pdo);
$dashboardController = new DashboardController($pdo);
$rankingController = new RankingController($pdo);
$affiliateController = new AffiliateController($pdo);
$contactController = new ContactController($pdo);

$orderModel = new Order($pdo);
$numberService = new \models\NumberService($pdo);

// ESCALA: nas rotas públicas não há sessão — pula a hidratação de user (e o session_start
// redundante que existia aqui). O $settingsController é criado SEMPRE (vários blocos públicos
// o usam via getSettings() direto), mas o $_SESSION['settings'] só é populado quando há sessão.
$settingsController = new SettingsController($pdo);

if (!$isPublicApiRoute) {
    if (isset($_SESSION['user']['id'])) {
        $userId = $_SESSION['user']['id'];
        $userModel = new \models\User($pdo);
        $userData = $userModel->getUserById($userId);

        $_SESSION['user'] = [
            'id' => $userData['id'],
            'name' => $userData['name'],
            'phone' => $userData['phone'],
            'email' => $userData['email'],
            'image_user' => $userData['image_user'],
            'age' => $userData['age'],
            'address' => $userData['address'],
            'created_at' => isoFromUtc($userData['created_at'] ?? null),
        ];
    }

    // Always refresh settings to get latest changes (só quando há sessão — o cache APCu do
    // getSettings() já evita a query repetida; ver models/Settings.php).
    $_SESSION['settings'] = $settingsController->getSettings();
}


// Gateways globais removidos - cada campanha tem suas próprias credenciais

// CRON FAKE - Só executa nas páginas de pagamento para não travar o site
$currentPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$isPaymentPage = (strpos($currentPath, '/compra/') !== false || $currentPath === '/status-order' || strpos($currentPath, '/obrigado/') !== false);

if ($isPaymentPage && isset($_SESSION['user']['id'])) {
    $cronFilePath = __DIR__ . '/storage/payment_paid_cron_run.txt';
    $userId = $_SESSION['user']['id'];

}


// Cron de cancelamento removido - agora o frontend expira pedidos em tempo real

/**
 * Processa pagamento confirmado por webhook
 * FUNÇÃO CENTRALIZADA - chamada por todos os webhooks de pagamento
 *
 * @param PDO $pdo Conexão com banco
 * @param string $txid ID da transação (correlationID, idTransaction, reference_code)
 * @param string $gateway Nome do gateway para logs (WOOVI, PAY2M, SUITPAY)
 * @return array ['success' => bool, 'message' => string, 'order_id' => int|null]
 */

/**
 * Envia evento de conversão para Facebook Conversion API
 */
/**
 * Envia UM evento server-side pra Facebook Conversions API (CAPI), multi-pixel.
 * Núcleo único de Purchase e InitiateCheckout — antes eram 2 funções ~140 linhas quase
 * idênticas (só diferiam em event_name, event_id e textos de log). Manter UMA fonte evita
 * que as duas divirjam silenciosamente num ajuste futuro (versão da API, hash, etc.) —
 * risco real em código de métrica/tráfego.
 *
 * As diferenças entre os eventos entram como PARÂMETROS:
 * @param string $eventName  'Purchase' | 'InitiateCheckout'
 * @param string $eventId    já calculado pelo caller (Purchase usa timestamp; IC é
 *                           determinístico p/ deduplicar com o fbq do front — ver wrappers)
 * @param int    $eventTime  o MESMO time() usado no event_id (evita divergir 1s na virada)
 * @param bool   $logConfig  loga a config do pixel (só o Purchase fazia isso — preserva)
 */
function sendFacebookEvent($pdo, $order, $user, $eventName, $eventId, $eventTime, $logConfig = false) {
    // Rótulo dos logs: Purchase não tinha prefixo; IC tinha "InitiateCheckout ". Preserva ambos.
    $logLabel = $eventName === 'Purchase' ? '' : $eventName . ' ';
    try {
        require_once __DIR__ . '/models/Settings.php';
        $settingsModel = new \models\Settings($pdo);
        $settings = $settingsModel->getSettings();

        $pixelId = $settings['id_pixel_facebook'] ?? '';
        $accessToken = $settings['pixel_token'] ?? '';
        $sendOption = $settings['pixel_send_option'] ?? 'always';

        if ($logConfig) {
            error_log('[FB CONVERSION] Config - Pixel ID: ' . ($pixelId ? substr($pixelId, 0, 6) . '...' : 'VAZIO') . ', Token: ' . ($accessToken ? 'PRESENTE' : 'VAZIO') . ', Send Option: ' . $sendOption);
        }

        if (empty($pixelId) || empty($accessToken)) {
            return; // Sem configuração de pixel/token
        }

        // Opção de envio: no modo fbc_only, só envia se tem FBC e a origem não é excluída.
        if ($sendOption === 'fbc_only') {
            $utmSource = strtolower($order['utm_source'] ?? '');
            $excludedSources = ['bio', 'group', 'community', 'stories', 'link'];

            $hasFbc = !empty($order['fbc']);
            $isExcludedSource = false;
            foreach ($excludedSources as $excluded) {
                if (strpos($utmSource, $excluded) !== false) {
                    $isExcludedSource = true;
                    break;
                }
            }

            if (!$hasFbc || $isExcludedSource) {
                error_log('[FB CONVERSION] ' . $logLabel . 'não enviado - FBC: ' . ($hasFbc ? 'sim' : 'não') . ', Source: ' . $utmSource);
                return;
            }
        }

        $value = floatval(str_replace(',', '.', $order['total']));
        $currency = 'BRL';

        // User data com hash SHA256
        $userData = [];

        if (!empty($user['email'])) {
            $userData['em'] = [hash('sha256', strtolower(trim($user['email'])))];
        }
        if (!empty($user['phone'])) {
            $phone = preg_replace('/[^0-9]/', '', $user['phone']);
            if (strlen($phone) == 11) {
                $phone = '55' . $phone;
            }
            $userData['ph'] = [hash('sha256', $phone)];
        }
        if (!empty($user['name'])) {
            $nameParts = explode(' ', trim($user['name']));
            $userData['fn'] = [hash('sha256', strtolower($nameParts[0]))];
            if (count($nameParts) > 1) {
                $userData['ln'] = [hash('sha256', strtolower(end($nameParts)))];
            }
        }

        // FBC e FBP se disponíveis
        if (!empty($order['fbc'])) {
            $userData['fbc'] = $order['fbc'];
        }
        if (!empty($order['fbp'])) {
            $userData['fbp'] = $order['fbp'];
        }

        // IP do cliente
        if (!empty($order['ip_address'])) {
            $userData['client_ip_address'] = $order['ip_address'];
        }

        // User Agent do cliente (FIX 2026-09-02: faltava no payload CAPI — Meta usa p/ match/dedup)
        if (!empty($order['user_agent'])) {
            $userData['client_user_agent'] = $order['user_agent'];
        }

        // Construir payload
        $eventData = [
            'event_name' => $eventName,
            'event_time' => $eventTime,
            'event_id' => $eventId,
            'event_source_url' => 'https://' . $GLOBALS['siteDomain'] . '/compra/' . $order['hash_order'],
            'action_source' => 'website',
            'user_data' => $userData,
            'custom_data' => [
                'value' => $value,
                'currency' => $currency,
                'content_ids' => [(string)$order['id_campaign']],
                'content_type' => 'product',
                'num_items' => (int)$order['quantity']
            ]
        ];

        $payload = [
            'data' => [$eventData]
        ];

        // Enviar para Facebook Conversion API — suporta múltiplos pixels/BMs
        $pixelIds = array_map('trim', explode(',', $pixelId));
        $tokens = array_map('trim', explode(',', $accessToken));

        foreach ($pixelIds as $i => $pid) {
            if (empty($pid)) continue;
            $token = $tokens[$i] ?? $tokens[0]; // fallback pro primeiro token se só tem 1

            $url = "https://graph.facebook.com/v18.0/{$pid}/events?access_token={$token}";

            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => json_encode($payload),
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 10
            ]);

            $response = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            if ($httpCode == 200) {
                error_log("[FB CONVERSION] {$logLabel}enviado para pixel {$pid} pedido: " . $order['id']);
            } else {
                error_log("[FB CONVERSION] {$logLabel}erro pixel {$pid}. HTTP: {$httpCode} Response: {$response}");
            }
        }

    } catch (\Exception $e) {
        error_log('[FB CONVERSION] ' . $logLabel . 'exceção: ' . $e->getMessage());
    }
}

/**
 * Purchase (server-side). event_id `order_{id}_{time}` — NÃO é deduplicado com client-side
 * (o Purchase client-side foi removido de propósito; só existe server). Chamada no webhook
 * de pagamento confirmado. Wrapper fino sobre sendFacebookEvent().
 */
function sendFacebookConversionEvent($pdo, $order, $user) {
    $eventTime = time();
    sendFacebookEvent($pdo, $order, $user, 'Purchase',
        'order_' . $order['id'] . '_' . $eventTime, $eventTime, true);
}

/**
 * InitiateCheckout — SÓ server-side (Conversions API), no momento em que o pedido é criado e o
 * PIX é gerado. Sem contraparte no navegador (o fbq foi removido de propósito: o híbrido não
 * valia o risco/complexidade). event_id determinístico `InitiateCheckout_{hash_order}` (estável
 * por pedido, não muda a cada envio). Wrapper fino sobre sendFacebookEvent().
 */
function sendFacebookInitiateCheckoutEvent($pdo, $order, $user) {
    sendFacebookEvent($pdo, $order, $user, 'InitiateCheckout',
        'InitiateCheckout_' . $order['hash_order'], time(), false);
}

function processConfirmedPayment($pdo, $txid, $gateway, $allowAutoReserve = false, $extraData = []) {
    global $campaignModel, $orderModel, $userModel;

    // Garantir que os models estão inicializados
    if (!$orderModel) {
        $orderModel = new \models\Order($pdo);
    }
    if (!$campaignModel) {
        $campaignModel = new \models\Campaign($pdo);
    }
    if (!$userModel) {
        $userModel = new \models\User($pdo);
    }

    $logPrefix = "[WEBHOOK {$gateway}]";

    try {
        // 1. Buscar pedido pelo txid
        // Nota: confirmSale() já tem proteção interna contra processamento duplicado
        $stmt = $pdo->prepare('SELECT * FROM orders WHERE txid = ?');
        $stmt->execute([$txid]);
        $order = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$order) {
            error_log("{$logPrefix} Pedido não encontrado: {$txid}");
            return ['success' => false, 'message' => 'Pedido não encontrado', 'order_id' => null];
        }

        // 2. Verificar se já foi processado (pago com números)
        if ($order['payment_status'] === ORDER_STATUS_PAGO && !empty($order['numbers'])) {
            error_log("{$logPrefix} Pedido já processado: {$order['id']}");
            return ['success' => true, 'message' => 'Pedido já processado', 'order_id' => $order['id']];
        }

        // 2.05 SEGURANÇA (anti-webhook-forjado): confirma o pagamento com o PRÓPRIO gateway antes de
        // liberar. Os webhooks não são autenticados — sem isto, um POST forjado com um txid válido
        // confirmaria o pagamento SEM PIX pago (fraude). Aqui perguntamos à API oficial do gateway
        // "esse txid está mesmo pago?" — o fraudador não forja essa resposta.
        // - Só no fluxo de WEBHOOK ($allowAutoReserve === false). A Revisão de Pagamentos manual
        //   ($allowAutoReserve === true) já consultou o gateway pra montar a lista, não revalida.
        // - SuitPay fica FORA: sua consulta está OFF (confirmado com eles) → validada por IP no
        //   handler /webhook/suitpay (allowlist). Aqui cobrimos os que TÊM consulta viável.
        $gatewayKey = strtolower(preg_replace('/_.*$/', '', $gateway));
        if (!$allowAutoReserve && in_array($gatewayKey, ['pay2m', 'magenpay', 'mercadopago', 'asaas', 'efi', 'avex'], true)) {
            require_once __DIR__ . '/gateways/gateway_helper.php';
            require_once __DIR__ . '/helpers/bool_normalize.php';
            require_once __DIR__ . '/helpers/gateway_config_helper.php';
            // Gateway GLOBAL: reconsulta com as credenciais de settings (não da campanha).
            $gatewayCheckCfg = resolveGatewayConfig($pdo);
            $verify = consultPaymentStatus($gatewayKey, $txid, $gatewayCheckCfg);
            if (empty($verify['success']) || empty($verify['paid'])) {
                // Não confirma. Fica PENDENTE pra Revisão de Pagamentos manual (não perde o pedido).
                // paid=false → possível fraude (não pagou); success=false → API do gateway indisponível.
                error_log("{$logPrefix} Pagamento NÃO confirmado pelo gateway (txid={$txid}): "
                    . json_encode(['status' => $verify['status'] ?? null, 'error' => $verify['error'] ?? null]));
                securityLog('webhook_pagamento_nao_confirmado', [
                    'gateway'  => $gateway,
                    'txid'     => $txid,
                    'order_id' => $order['id'],
                    'status'   => $verify['status'] ?? null,
                    'error'    => $verify['error'] ?? null,
                ]);
                return [
                    'success'  => false,
                    'message'  => 'Pagamento não confirmado pelo gateway — pendente de revisão',
                    'order_id' => $order['id'],
                ];
            }
            error_log("{$logPrefix} Pagamento CONFIRMADO pelo gateway (txid={$txid}, status=" . ($verify['status'] ?? '?') . ")");
        }

        // 2.1 Lock atômico: marcar como PAGO imediatamente
        $stmtLock = $pdo->prepare("UPDATE orders SET payment_status = 'pago', webhook_paid_at = NOW() WHERE id = ? AND payment_status = 'pendente'");
        $stmtLock->execute([$order['id']]);
        if ($stmtLock->rowCount() === 0) {
            // O lock casou 0 linhas → o pedido NÃO estava 'pendente' neste instante.
            // A cópia $order (lida lá em cima) pode estar STALE: entre aquela leitura e este
            // UPDATE o status real pode ter mudado — em especial, o cron de expiração
            // (auto_expire_orders, roda a cada 1min) pode ter EXPIRADO o pedido no meio.
            // Por isso NÃO confiamos em $order['payment_status']; relemos o estado REAL do banco.
            $stmtReal = $pdo->prepare("SELECT payment_status, numbers, webhook_paid_at FROM orders WHERE id = ?");
            $stmtReal->execute([$order['id']]);
            $real = $stmtReal->fetch(PDO::FETCH_ASSOC) ?: [];
            $realStatus = $real['payment_status'] ?? $order['payment_status'];

            // Já estava pago (outro webhook ganhou a corrida, ou processamento anterior): idempotente.
            if ($realStatus === 'pago') {
                return ['success' => true, 'message' => 'Pedido já processado', 'order_id' => $order['id']];
            }

            // CORRIDA PAGAMENTO × EXPIRAÇÃO: o pedido foi expirado/cancelado antes do lock, mas
            // o PIX FOI pago (o gateway confirmou acima, ou é Revisão manual). Sem isto o pagamento
            // sumiria — pedido expirado, número liberado e nada registrando que pagou. Gravamos
            // webhook_paid_at para a "Revisão de Pagamentos" reprocessar manualmente (sem auto-reserva
            // aqui, porque o número já pode ter sido tomado por outro comprador).
            if (in_array($realStatus, ['expirado', 'cancelado'], true) && !$allowAutoReserve) {
                $stmtWebhook = $pdo->prepare("UPDATE orders SET webhook_paid_at = NOW() WHERE id = ? AND webhook_paid_at IS NULL");
                $stmtWebhook->execute([$order['id']]);
                error_log("{$logPrefix} CORRIDA: pedido {$order['id']} foi '{$realStatus}' antes do lock, mas PIX pago. webhook_paid_at salvo — processar via Revisar Pagamentos.");
                securityLog('webhook_corrida_pagamento_expiracao', [
                    'gateway'  => $gateway,
                    'txid'     => $txid,
                    'order_id' => $order['id'],
                    'status'   => $realStatus,
                ]);
                return [
                    'success'         => true,
                    'message'         => 'Pedido expirado antes da confirmação - pagamento registrado. Use Revisar Pagamentos para processar.',
                    'order_id'        => $order['id'],
                    'status'          => $realStatus,
                    'webhook_paid_at' => date('c'),
                ];
            }

            error_log("{$logPrefix} Pedido {$order['id']} já sendo processado por outro webhook (status real: {$realStatus})");
            return ['success' => true, 'message' => 'Pedido já sendo processado', 'order_id' => $order['id']];
        }

        // NUCLEO extraido p/ OrderPaymentService::generateNumbersForPaidOrder (fonte unica:
        // webhook+manual+worker chamam o mesmo). Faz geoloc, decrementa pending, busca campanha,
        // gera numero (Automatica/Manual) e roda efeitos LOCAIS. Comportamento identico ao antigo.
        // A cauda EXTERNA vem em deferred_external (o dispatch roda no tail via fastcgi_finish_request).
        $__gen = (new \services\OrderPaymentService($pdo))->generateNumbersForPaidOrder($order, $logPrefix, $allowAutoReserve);
        // Propaga igual ao antigo: falha de geração/reserva OU caso Manual-sem-números ('expirado',
        // que no fluxo antigo retornava direto sem rodar e2e/cauda). Só segue adiante quando gerou nº.
        if (empty($__gen['success']) || ($__gen['status'] ?? null) === 'expirado') { return $__gen; }
        $confirmed = $__gen['numbers_confirmed'] ?? 0;

        // Salvar dados extras do webhook (e2e, approved_by) — ANTES de responder, é DB rápido.
        $e2e = $extraData['e2e'] ?? null;
        $approvedBy = strtolower($gateway); // Gateway que confirmou o pagamento
        if ($e2e || $approvedBy) {
            $stmtExtra = $pdo->prepare("UPDATE orders SET e2e = COALESCE(?, e2e), approved_by = COALESCE(?, approved_by) WHERE id = ?");
            $stmtExtra->execute([$e2e, $approvedBy, $order['id']]);
        }

        error_log("{$logPrefix} Processamento concluído para pedido: {$order['id']}");

        return [
            'success' => true,
            'message' => 'Pagamento processado com sucesso',
            'order_id' => $order['id'],
            'numbers_confirmed' => $confirmed,
            // Cauda externa adiada: o dispatch roda isto APÓS responder 200 ao gateway
            // (fastcgi_finish_request). Vem do método (order+campaign+user já recarregados lá).
            // Se o chamador não adiar, DEVE rodar runExternalEffects.
            'deferred_external' => $__gen['deferred_external'] ?? null,
        ];

    } catch (\Throwable $e) {
        // \Throwable (não só \Exception): captura também \Error/\TypeError (ex.: método em null)
        // no caminho do dinheiro — senão um fatal escapava sem tratamento. Mensagem genérica na
        // resposta (não vaza detalhe ao gateway); o erro real fica no log.
        error_log("{$logPrefix} Erro ao processar pagamento: " . $e->getMessage());
        return [
            'success' => false,
            'message' => 'Erro interno ao processar o pagamento.',
            'order_id' => $order['id'] ?? null
        ];
    }
}

function route($uri) {
    global $campaignController, $campaignModel, $purchaseController, $ordersController,
    $authMiddleware, $dashboardController, $rankingController,
    $settingsController, $affiliateController, $contactController, $orderModel, $settings, $pdo;

    // Detecta subdomínios
    $host = $_SERVER['HTTP_HOST'] ?? '';
    $isSubdomainPainel = (strpos($host, 'painel.') === 0);
    $isSubdomainApi = (strpos($host, 'api.') === 0);

    // Pega o domínio principal (sem o subdomínio)
    $mainDomain = preg_replace('/^(painel\.|api\.)/', '', $host);
    $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';

    $parsedUrl = parse_url($uri);
    $path = $parsedUrl['path'] ?? '/';

    // Remove trailing slash (except for root path)
    if ($path !== '/' && substr($path, -1) === '/') {
        $path = rtrim($path, '/');
    }

    // OBSERVABILIDADE: /health e /api/health — estado operacional pra uptime check externo.
    // Sem auth, sem dado sensível (só contadores). Responde 200 se saudável, 503 se degradado.
    if ($path === '/health' || $path === '/api/health') {
        header('Content-Type: application/json; charset=utf-8');
        $h = ['ok' => true, 'checks' => []];
        try {
            // 1) worker vivo? (heartbeat < 3min). Ausente/antigo = degradado.
            $wh = $pdo->query("SELECT last_run_at, EXTRACT(EPOCH FROM (NOW() - last_run_at)) AS age FROM worker_health WHERE name = 'pix-worker'")->fetch(\PDO::FETCH_ASSOC);
            $workerAge = $wh ? (int) $wh['age'] : null;
            $workerOk = $wh !== false && $workerAge !== null && $workerAge < 180;
            $h['checks']['pix_worker'] = ['ok' => $workerOk, 'last_run_seconds_ago' => $workerAge];

            // 2) pedidos presos (gerando/pendente sem code_pix há > 5min) — sinal de fila travada.
            $stuck = (int) $pdo->query("
                SELECT COUNT(*) FROM orders
                WHERE pix_status IN ('gerando','pendente') AND (code_pix IS NULL OR code_pix = '')
                  AND payment_status = 'pendente'
                  AND pix_requested_at < NOW() - INTERVAL '5 minutes'
            ")->fetchColumn();
            $h['checks']['stuck_pix'] = ['ok' => $stuck === 0, 'count' => $stuck];

            // 3) idade do último pagamento confirmado por webhook (informativo — não derruba).
            $lastPaid = $pdo->query("SELECT EXTRACT(EPOCH FROM (NOW() - MAX(webhook_paid_at))) FROM orders WHERE webhook_paid_at IS NOT NULL")->fetchColumn();
            $h['checks']['last_webhook_paid_seconds_ago'] = $lastPaid !== null ? (int) $lastPaid : null;

            $h['ok'] = $workerOk && $stuck === 0;
        } catch (\Throwable $e) {
            $h['ok'] = false;
            $h['error'] = 'health check falhou';
            error_log('[health] ' . $e->getMessage());
        }
        http_response_code($h['ok'] ? 200 : 503);
        echo json_encode($h);
        exit();
    }

    // Se está no subdomínio api.*, adiciona /api ao path automaticamente
    // Assim api.seudominio.com/auth/sign-in vira /api/auth/sign-in internamente
    // Exceto para webhooks que já têm rota própria
    $isWebhookPath = strpos($path, '/webhook') === 0;
    if ($isSubdomainApi && strpos($path, '/api') !== 0 && $path !== '/' && !$isWebhookPath) {
        $path = '/api' . $path;
    } elseif ($isSubdomainApi && $path === '/') {
        // api.seudominio.com/ retorna info da API
        jsonOk([
            'version' => trim(@file_get_contents(__DIR__ . '/VERSION') ?: '1.0.0'),
            'endpoints' => [
                'auth' => '/auth/sign-in',
                'campaigns' => '/campaigns',
                'public' => '/public/*'
            ]
        ], 'Sorte Maxima API');
    }


    // Painel antigo foi descontinuado - rotas /dashboard/* são bloqueadas
    // As APIs (/api/*) continuam funcionando normalmente
    $isLocalhost = (strpos($host, 'localhost') !== false || strpos($host, '127.0.0.1') !== false || filter_var($host, FILTER_VALIDATE_IP));
    $painelUrl = $isLocalhost ? 'http://localhost:3032' : $protocol . '://painel.' . $mainDomain;

    if (strpos($path, '/dashboard') === 0 && strpos($path, '/api/') === false) {
        jsonError('Painel descontinuado. Use o novo painel.', 404, ['redirect' => $painelUrl]);
    }

    // Se está no subdomínio painel - redireciona para o novo painel Next.js
    if ($isSubdomainPainel) {
        // APIs continuam funcionando
        if (strpos($path, '/api/') === 0) {
            // OK, rota de API - continua processando
        }
        // Todas as outras rotas - redireciona para novo painel
        else {
            header("Location: {$painelUrl}", true, 301);
            exit();
        }
    }


    // REMOVIDO: POST /compra - Rota legado do frontend PHP antigo
    // O frontend Next.js usa /api/checkout que já tem antispam implementado

    // REMOVIDO (31/03/2026): Rotas legado /get-expiration-time e /compra/{hash}
    // Next.js usa /api/public/order/{id}/status para polling
    // Next.js renderiza /compra/{hash} via frontend-public (porta 3000)

    if ($_SERVER['REQUEST_METHOD'] == 'GET' && $path == '/check-phone') {
        // Rate limiting: 10 requisições por minuto por IP
        if (!checkRateLimit($pdo, 'check-phone', 10, 60)) {
            securityLog('rate_limit_exceeded', ['endpoint' => '/check-phone']);
            respondRateLimitExceeded();
        }

        $phone = $_GET['phone'] ?? '';
        $campaignId = $_GET['campaign_id'] ?? null;

        // Validar formato do telefone (deve ter pelo menos 10 dígitos)
        $phoneClean = preg_replace('/[^0-9]/', '', $phone);
        if (strlen($phoneClean) < 10) {
            header('Content-Type: application/json');
            echo json_encode(['success' => false, 'message' => 'Telefone inválido']);
            exit();
        }

        $user = $purchaseController->checkPhone($phone);

        header('Content-Type: application/json');
        if ($user) {
            // Buscar campos requeridos da campanha
            $requiresCpf = false;
            $requiresAge = false;
            if ($campaignId) {
                $campaignData = $campaignModel->getCampaignById($campaignId);
                $requiresCpf = ($campaignData['required_cpf'] ?? 0) == true;
                $requiresAge = filter_var($campaignData['required_birth_date'] ?? false, FILTER_VALIDATE_BOOLEAN);
            }
            $missingCpf = $requiresCpf && empty($user['cpf']);
            $missingAge = $requiresAge && empty($user['age']);

            // Rota PÚBLICA (sem senha) — mascarar dados pessoais para não vazar por sondagem
            // de telefone. Só primeiro nome, email/phone/cpf mascarados. Ver
            // docs/AUDITORIA_MASCARA_DADOS_APIS_PUBLICAS.md.
            $safeUser = [
                'id' => $user['id'],
                'name' => formatFirstName($user['name']),
                'phone' => isset($user['phone']) ? maskPhone($user['phone']) : '',
                'email' => !empty($user['email']) ? maskEmail($user['email']) : '',
                'cpf' => !empty($user['cpf']) ? '***.***.***-**' : '', // Mascara CPF
                'image_user' => $user['image_user'] ?? '',
                'age' => $user['age'] ?? '',
            ];

            echo json_encode([
                'success' => true,
                'user' => $safeUser,
                'requires_update' => $missingCpf || $missingAge,
                'missing_fields' => [
                    'cpf' => $missingCpf,
                    'age' => $missingAge
                ]
            ]);
        } else {
            echo json_encode(['success' => false]);
        }
        exit();
    }

    // Check CPF - Buscar usuário por CPF
    if ($_SERVER['REQUEST_METHOD'] == 'GET' && $path == '/check-cpf') {
        // Rate limiting: 10 requisições por minuto por IP
        if (!checkRateLimit($pdo, 'check-cpf', 10, 60)) {
            securityLog('rate_limit_exceeded', ['endpoint' => '/check-cpf']);
            respondRateLimitExceeded();
        }

        $cpf = $_GET['cpf'] ?? '';
        $campaignId = $_GET['campaign_id'] ?? null;

        // Validar formato do CPF (deve ter 11 dígitos)
        $cpfClean = preg_replace('/[^0-9]/', '', $cpf);
        if (strlen($cpfClean) !== 11) {
            header('Content-Type: application/json');
            echo json_encode(['success' => false, 'message' => 'CPF inválido']);
            exit();
        }

        $user = $purchaseController->checkCpf($cpf);

        header('Content-Type: application/json');
        if ($user) {
            // Buscar campos requeridos da campanha
            $requiresAge = false;
            if ($campaignId) {
                $campaignData = $campaignModel->getCampaignById($campaignId);
                $requiresAge = filter_var($campaignData['required_birth_date'] ?? false, FILTER_VALIDATE_BOOLEAN);
            }
            $missingAge = $requiresAge && empty($user['age']);

            // Login do usuário por CPF
            $_SESSION['user'] = $user;

            // Rota PÚBLICA (sem senha) — mascarar dados pessoais para não vazar por sondagem
            // de CPF. Só primeiro nome, email/phone mascarados. Ver
            // docs/AUDITORIA_MASCARA_DADOS_APIS_PUBLICAS.md.
            $safeUser = [
                'id' => $user['id'],
                'name' => formatFirstName($user['name']),
                'phone' => isset($user['phone']) ? maskPhone($user['phone']) : '', // Mascara telefone
                'email' => !empty($user['email']) ? maskEmail($user['email']) : '',
                'cpf' => $user['cpf'], // CPF já foi informado pelo usuário
                'image_user' => $user['image_user'] ?? '',
                'age' => $user['age'] ?? '',
            ];

            echo json_encode([
                'success' => true,
                'user' => $safeUser,
                'requires_update' => $missingAge,
                'missing_fields' => [
                    'cpf' => false,
                    'age' => $missingAge
                ]
            ]);
        } else {
            echo json_encode(['success' => false]);
        }
        exit();
    }

    // Atualizar campos obrigatórios do usuário (CPF e idade)
    if ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/update-user-fields') {
        header('Content-Type: application/json');

        $phone = $_POST['phone'] ?? '';
        $cpf = $_POST['cpf'] ?? '';
        $age = $_POST['age'] ?? '';

        if (empty($phone)) {
            echo json_encode(['success' => false, 'message' => 'Telefone não informado']);
            exit();
        }

        // Validar idade mínima — fonte única (validateBirthDate), em vez de recalcular inline.
        $minAge = intval($settings['min_age'] ?? 18);
        if (!empty($age)) {
            $birthCheck = validateBirthDate($age, $minAge);
            if (!$birthCheck['valid']) {
                echo json_encode(['success' => false, 'message' => $birthCheck['message']]);
                exit();
            }
        }

        try {
            $user = $purchaseController->checkPhone($phone);

            if (!$user) {
                echo json_encode(['success' => false, 'message' => 'Usuário não encontrado']);
                exit();
            }

            // Atualizar apenas os campos CPF e age
            $stmt = $pdo->prepare("UPDATE users SET cpf = :cpf, age = :age WHERE id = :id");
            $stmt->execute([
                ':cpf' => $cpf,
                ':age' => $age,
                ':id' => $user['id']
            ]);

            echo json_encode(['success' => true, 'message' => 'Dados atualizados com sucesso']);
        } catch (Exception $e) {
            echo json_encode(['success' => false, 'message' => 'Erro ao atualizar dados']);
        }
        exit();
    }

    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/campaign/edit-{hash} (GET) → Next.js /dashboard/campaigns/[id]/edit
    // - /dashboard/campaign/edit-{id} (GET) → Next.js /dashboard/campaigns/[id]/edit
    // ==========================================================

    // REMOVIDO (31/03/2026): Rota legado /obrigado/{hash}
    // Next.js renderiza tudo em /compra/{hash} (pendente + pago)

    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/campaign/edit/{id} (POST) → /api/campaigns/save
    // - /dashboard/user/edit (POST) → /api/user/update/{hash}
    // - /dashboard/user/toggle-block (POST) → /api/user/toggle-block/{id}
    // - /dashboard/order/cancel (POST) → OrderController API
    // - /dashboard/order/confirm (POST) → OrderController API
    // ==========================================================
    
    // ROTAS DE PÁGINA REMOVIDAS (site público server-rendered antigo): /, /campanha/{slug},
    // /campanhas, /comunicados, /termo-de-uso, /cadastrar, /register (POST), /login, /afiliado.
    // Todas renderizavam views/pages/*.php que já foram deletadas; o frontend Next.js
    // (frontend-public) serve essas rotas de verdade (app/*, com links no navbar). O tracking de
    // afiliado/UTM/pixel dessas páginas migrou pro Next (/api/public/affiliate/hit); o cadastro
    // posta em /api/register. Ver docs/2026-08-05_FORCA_TAREFA_LIMPEZA_LEGADO.md.
    // ROTAS REMOVIDAS: /loginByPhone, /logout, /logout-ajax, /loginAdmin, /logoutAdmin
    // Login público: /api/login-ajax | Admin: /api/auth/sign-in | Logout: client-side (AuthUtils)
    // ROTAS REMOVIDAS: /dashboard/app, /dashboard/home, /dashboard/campaigns (views),
    // /dashboard/campaign/preview-*, /dashboard/preview/scratch-card, /dashboard/campaigns/{hash}/orders
    // Substituídas pelo frontend Next.js (frontend-prod)
    if (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/dashboard$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/dashboard - Dados do dashboard da campanha
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['message' => 'Campanha não encontrada']);
            exit();
        }

        // Instancia os models necessários
        $statisticsModel = new \models\Statistics($pdo);
        $orderModel = new \models\Order($pdo);

        // Busca estatísticas da campanha
        $campaignId = $campaign['id'];
        $campaignSlug = $campaign['slug'] ?? '';
        $faturamento = $statisticsModel->getTotalRevenue($campaignId);
        $totalVendas = $statisticsModel->getPagosOrdersCount($campaignId);
        $ticketMedio = $totalVendas > 0 ? $faturamento / $totalVendas : 0;
        $participantes = $statisticsModel->getParticipantsCount($campaignId);

        // Visitas da campanha
        $visitasHoje = $statisticsModel->getVisitsTodayByCampaign($campaignSlug);
        $visitasOntem = $statisticsModel->getVisitsYesterdayByCampaign($campaignSlug);
        $visitasTotal = $statisticsModel->getVisitsTotalByCampaign($campaignSlug);
        $visitasLastWeek = $statisticsModel->getVisitsLastWeekByCampaign($campaignSlug);

        // Dados comparativos (hoje vs ontem)
        $revenueToday = $statisticsModel->getRevenueTodayByCampaign($campaignId);
        $revenueYesterday = $statisticsModel->getRevenueYesterdayByCampaign($campaignId);
        $ordersToday = $statisticsModel->getOrdersTodayByCampaign($campaignId);
        $ordersYesterday = $statisticsModel->getOrdersYesterdayByCampaign($campaignId);
        $participantsToday = $statisticsModel->getParticipantsTodayByCampaign($campaignId);
        $participantsYesterday = $statisticsModel->getParticipantsYesterdayByCampaign($campaignId);

        // Expirados
        $expiradosCount = $statisticsModel->getExpiradosCount($campaignId);
        $expiradosTotal = $statisticsModel->getExpiradosTotal($campaignId);

        // Descontos
        $discountStats = $statisticsModel->getDiscountStats($campaignId);
        $descontosCount = $discountStats['count'];
        $descontosTotal = $discountStats['total'];

        // Upsells
        $upsellStats = $statisticsModel->getUpsellStats($campaignId);
        $upsellCount = $upsellStats['count'];
        $upsellTotal = $upsellStats['total'];

        // Top 10 compradores — ordenação vem da configuração global "Ranking de Compras"
        // (settings.ranking_order_by), a mesma dos rankings do site público. Era fixo em
        // quantidade, então painel e público podiam mostrar ordens diferentes.
        $topBuyers = $orderModel->getTopBuyersByCampaign($campaignId, 10, rankingOrderBy($settings));

        // Dados do gráfico (últimas 24 horas)
        $chartStats = $orderModel->getLast24HoursStatsByCampaign($campaignId);

        echo json_encode([
            'data' => [
                'campaign' => [
                    'id' => $campaign['id'],
                    'hash_id' => $campaign['hash_id'],
                    'name' => $campaign['name'],
                    'subname' => $campaign['subname'] ?? '',
                    'image_url' => $campaign['image_url'] ?? '',
                    'price' => (float)str_replace(',', '.', $campaign['price'] ?? 0),
                    'draw_date' => $campaign['draw_date'] ?? null,
                    'status' => $campaign['status'] ?? '',
                ],
                'stats' => [
                    'faturamento' => (float)$faturamento,
                    'totalVendas' => (int)$totalVendas,
                    'ticketMedio' => (float)$ticketMedio,
                    'participantes' => (int)$participantes,
                    'visitasHoje' => (int)$visitasHoje,
                    'visitasOntem' => (int)$visitasOntem,
                    'visitasTotal' => (int)$visitasTotal,
                    'visitasLastWeek' => (int)$visitasLastWeek,
                    'revenueToday' => (float)$revenueToday,
                    'revenueYesterday' => (float)$revenueYesterday,
                    'ordersToday' => (int)$ordersToday,
                    'ordersYesterday' => (int)$ordersYesterday,
                    'participantsToday' => (int)$participantsToday,
                    'participantsYesterday' => (int)$participantsYesterday,
                    'expiradosCount' => (int)$expiradosCount,
                    'expiradosTotal' => (float)$expiradosTotal,
                    'descontosCount' => (int)$descontosCount,
                    'descontosTotal' => (float)$descontosTotal,
                    'upsellCount' => (int)$upsellCount,
                    'upsellTotal' => (float)$upsellTotal,
                ],
                'topBuyers' => $topBuyers,
                'chartStats' => $chartStats,
            ]
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/billing$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/billing - Dados de faturamento da campanha
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];
        $orderModel = new \models\Order($pdo);

        // Busca faturamento separado por tipo (automático vs manual)
        $billingStats = $orderModel->getBillingStatsByCampaign($campaignId);

        echo json_encode([
            'success' => true,
            'data' => $billingStats
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/section$/', $path, $matches) && ($_SERVER['REQUEST_METHOD'] === 'PATCH' || $_SERVER['REQUEST_METHOD'] === 'PUT')) {
        // API Route: PATCH /api/campaigns/{hash_id}/section
        // Atualização PARCIAL e SEGURA de seções isoladas da campanha (Alerta / Upsell /
        // Promoções), sem passar pelo /save (que reescreve a linha inteira e zeraria imagens).
        // Só as colunas da whitelist abaixo podem ser tocadas; nomes de coluna vêm SEMPRE
        // do servidor (nunca do input) e os valores entram só como bind PDO.
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $input = getJsonBody();
        $fields = is_array($input['fields'] ?? null) ? $input['fields'] : [];

        // Whitelist tipada: coluna => tipo de normalização. NUNCA inclui price, imagens,
        // gateways, status, id, hash_id, user_id.
        $allowed = [
            // Alerta
            'enable_alert' => 'bool', 'title_alert' => 'text', 'description_alert' => 'text',
            'initial_alert' => 'datetime', 'final_alert' => 'datetime', 'auto_restart_alert' => 'bool',
            // Upsell
            'upsell_active' => 'bool', 'upsell_title' => 'text', 'upsell_description' => 'text',
            'upsell_values' => 'json',
            // Promoções — dobro
            'enable_double_chance' => 'bool', 'initial_double_chance' => 'datetime',
            'final_double_chance' => 'datetime', 'auto_restart_double_chance' => 'bool',
            'title_double_chance' => 'text', 'description_double_chance' => 'text',
            'double_chance_multiplier' => 'multiplier', 'double_chance_min_qty' => 'intnull',
            // Promoções — quantidade
            'enable_promotions' => 'bool', 'promotions_type' => 'text',
            'promotions_cumulative' => 'bool', 'promos' => 'json',
        ];

        // Validação de negócio defensiva: promoções exigem campanha paga e não-manual.
        $isFree = filter_var($campaign['free'] ?? false, FILTER_VALIDATE_BOOLEAN);
        $isManual = ($campaign['type_raffle'] ?? '') === 'Manual';
        $touchesPromo = array_intersect_key($fields, array_flip([
            'enable_promotions', 'promos', 'promotions_type', 'promotions_cumulative',
            'enable_double_chance', 'initial_double_chance', 'final_double_chance',
            'auto_restart_double_chance', 'title_double_chance', 'description_double_chance',
            'double_chance_multiplier', 'double_chance_min_qty',
        ]));
        $touchesUpsell = array_intersect_key($fields, array_flip([
            'upsell_active', 'upsell_title', 'upsell_description', 'upsell_values',
        ]));
        if (($touchesPromo || $touchesUpsell) && ($isFree || $isManual)) {
            echo json_encode(['success' => false, 'message' => 'Promoções e upsell não estão disponíveis para campanhas manuais ou gratuitas']);
            exit();
        }

        // Validação de preço das promoções por quantidade (promo < preço cheio).
        if (isset($fields['promos']) && filter_var($fields['enable_promotions'] ?? ($campaign['enable_promotions'] ?? false), FILTER_VALIDATE_BOOLEAN)) {
            $unitPrice = (float)($campaign['price'] ?? 0);
            $promosArr = is_array($fields['promos']) ? $fields['promos'] : (json_decode($fields['promos'], true) ?: []);
            foreach ($promosArr as $i => $p) {
                $qty = (int)($p['qty'] ?? 0);
                $pp = (float)($p['price'] ?? 0);
                if ($qty > 0 && $pp >= $qty * $unitPrice) {
                    echo json_encode(['success' => false, 'message' => 'Promoção ' . ($i + 1) . ': o preço deve ser menor que ' . number_format($qty * $unitPrice, 2, ',', '.')]);
                    exit();
                }
            }
        }

        // Monta o UPDATE só com os campos permitidos que vieram.
        $setParts = [];
        $binds = [];
        $boolParams = [];
        foreach ($fields as $key => $rawValue) {
            if (!isset($allowed[$key])) continue; // fora da whitelist → ignora
            $type = $allowed[$key];
            $ph = ':' . $key;
            $setParts[] = "$key = $ph";
            switch ($type) {
                case 'bool':
                    $binds[$ph] = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN);
                    $boolParams[$ph] = true;
                    break;
                case 'datetime':
                    $binds[$ph] = !empty($rawValue) ? str_replace('T', ' ', $rawValue) . ':00' : null;
                    break;
                case 'multiplier':
                    $binds[$ph] = max(2, (int)$rawValue);
                    break;
                case 'intnull':
                    $binds[$ph] = ($rawValue === '' || $rawValue === null) ? null : (int)$rawValue;
                    break;
                case 'json':
                    $binds[$ph] = is_array($rawValue) ? (empty($rawValue) ? null : json_encode($rawValue)) : ($rawValue ?: null);
                    break;
                case 'text':
                default:
                    $binds[$ph] = ($rawValue === null) ? null : (string)$rawValue;
                    break;
            }
        }

        if (empty($setParts)) {
            echo json_encode(['success' => false, 'message' => 'Nenhum campo válido para atualizar']);
            exit();
        }

        try {
            $sql = 'UPDATE campaigns SET ' . implode(', ', $setParts) . ' WHERE id = :__id';
            $stmt = $pdo->prepare($sql);
            foreach ($binds as $ph => $val) {
                if (isset($boolParams[$ph])) {
                    $stmt->bindValue($ph, $val, \PDO::PARAM_BOOL);
                } elseif ($val === null) {
                    $stmt->bindValue($ph, null, \PDO::PARAM_NULL);
                } else {
                    $stmt->bindValue($ph, $val);
                }
            }
            $stmt->bindValue(':__id', (int)$campaign['id'], \PDO::PARAM_INT);
            $stmt->execute();
            echo json_encode(['success' => true, 'message' => 'Seção atualizada']);
        } catch (Exception $e) {
            error_log('section update error: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro ao atualizar seção']);
        }
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/sales-by-day$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/sales-by-day - Vendas por dia
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];

        // Query para vendas por dia (agrupado pelo dia de Brasília — created_at é UTC no banco)
        $sql = "SELECT
            (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Sao_Paulo')::date as dia,
            COUNT(*) as compras,
            SUM(quantity) as numeros,
            SUM(CAST(REPLACE(total, ',', '.') AS NUMERIC)) as total,
            -- Ticket médio do dia = faturamento / nº de compras. MESMA fórmula do card
            -- 'Ticket Médio' do dashboard (index.php:~1902), pra não existirem dois números
            -- diferentes com o mesmo nome. COUNT(*) nunca é 0 dentro de um GROUP BY.
            (SUM(CAST(REPLACE(total, ',', '.') AS NUMERIC)) / COUNT(*)) as ticket_medio
            FROM orders
            WHERE id_campaign = ? AND payment_status = 'pago'
            GROUP BY (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Sao_Paulo')::date
            ORDER BY (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Sao_Paulo')::date DESC";

        $stmt = $pdo->prepare($sql);
        $stmt->execute([$campaignId]);
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

        // Formatar dados
        $data = array_map(function($row) {
            return [
                'dia' => $row['dia'],
                'compras' => (int)$row['compras'],
                'numeros' => (int)$row['numeros'],
                'ticket_medio' => (float)$row['ticket_medio'],
                'total' => (float)$row['total']
            ];
        }, $rows);

        echo json_encode([
            'success' => true,
            'data' => $data
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/review-payments$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        // API Route: POST /api/campaigns/{hash_id}/review-payments - Revisar pagamentos expirados
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();

        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];

        // Inclui o helper de gateways para consulta de status
        require_once __DIR__ . '/gateways/gateway_helper.php';

        $processed = 0;
        $failed = 0;
        $errors = [];
        $checkedApi = 0;
        $paidViaApi = 0;

        // Detecta qual gateway está configurado na campanha
        $gateway = detectCampaignGateway($campaign);
        $gatewayName = $gateway ? strtoupper($gateway) : 'N/A';

        // FASE 1: Processa pedidos que já têm webhook_paid_at (confirmados via webhook)
        // Isso funciona independentemente da API do gateway estar funcionando
        $sqlWebhookConfirmed = "SELECT id, hash_order, txid, webhook_paid_at FROM orders
                WHERE id_campaign = ?
                AND payment_status IN ('pendente', 'cancelado', 'expirado')
                AND webhook_paid_at IS NOT NULL
                AND txid IS NOT NULL
                ORDER BY webhook_paid_at DESC
                LIMIT 50";

        $stmtWebhook = $pdo->prepare($sqlWebhookConfirmed);
        $stmtWebhook->execute([$campaignId]);
        $webhookConfirmedOrders = $stmtWebhook->fetchAll(PDO::FETCH_ASSOC);

        $confirmedByWebhook = 0;
        foreach ($webhookConfirmedOrders as $order) {
            $checkedApi++;

            // Processa diretamente pois já foi confirmado via webhook
            $result = processConfirmedPayment($pdo, $order['txid'], $gatewayName . '_WEBHOOK_REVIEW', true);

            // Revisão roda em background (sem pico) → cauda externa SÍNCRONA (não adiar). O webhook
            // agora adia a cauda pro tail via deferred_external; aqui executamos na hora.
            if (!empty($result['deferred_external'])) {
                try {
                    $__d = $result['deferred_external'];
                    (new \services\OrderPaymentService($pdo))->runExternalEffects($__d['order'], $__d['campaign'] ?? [], $__d['user'] ?? null);
                } catch (\Throwable $__te) { error_log('[revisão] efeitos externos ignorados: ' . $__te->getMessage()); }
            }

            if ($result['success']) {
                $processed++;
                $confirmedByWebhook++;
                adminLog('payment_reviewed', [
                    'order_hash' => $order['hash_order'],
                    'order_id' => $order['id'],
                    'txid' => $order['txid'],
                    'campaign_id' => $campaignId,
                    'gateway' => $gateway,
                    'source' => 'webhook_confirmed',
                    'webhook_paid_at' => $order['webhook_paid_at']
                ]);
            } else {
                $failed++;
                $errors[] = [
                    'order_id' => $order['id'],
                    'hash_order' => $order['hash_order'],
                    'error' => $result['message'] ?? 'Erro ao processar pagamento confirmado via webhook'
                ];
            }
        }

        // FASE 2: Para pedidos SEM webhook_paid_at, tenta consultar API do gateway.
        // Usa o gateway DE CADA PEDIDO (não o ativo na campanha): se a campanha trocou
        // de gateway, os pedidos antigos precisam ser consultados no gateway em que
        // foram criados, senão a API do gateway novo não reconhece o txid → pagamento
        // confirmado fica marcado como expirado (perda de venda real).
        // Doc: docs/2026-05-08_FIX_REVIEW_PAYMENTS_GATEWAY.md
        { // Fase 2 roda sempre — usa o gateway de cada pedido
            // Busca pedidos pendentes SEM webhook_paid_at (com o gateway de cada um)
            $sqlPending = "SELECT id, hash_order, txid, gateway as order_gateway FROM orders
                    WHERE id_campaign = ?
                    AND payment_status IN ('pendente', 'cancelado', 'expirado')
                    AND webhook_paid_at IS NULL
                    AND txid IS NOT NULL
                    AND txid != ''
                    ORDER BY created_at DESC
                    LIMIT 50";

            $stmtPending = $pdo->prepare($sqlPending);
            $stmtPending->execute([$campaignId]);
            $pendingOrders = $stmtPending->fetchAll(PDO::FETCH_ASSOC);

            foreach ($pendingOrders as $order) {
                $checkedApi++;

                // Gateway DO PEDIDO (onde ele foi criado); fallback pro da campanha
                // se o pedido antigo não tiver gateway salvo.
                $orderGateway = !empty($order['order_gateway']) ? $order['order_gateway'] : $gateway;
                if (empty($orderGateway)) {
                    continue; // sem gateway pra consultar — pula
                }

                try {
                    // Consulta status na API do gateway DO PEDIDO
                    $statusResult = consultPaymentStatus($orderGateway, $order['txid'], $campaign);

                    if ($statusResult['success'] && $statusResult['paid']) {
                        $paidViaApi++;

                        // Processa o pagamento
                        $result = processConfirmedPayment($pdo, $order['txid'], strtoupper($orderGateway) . '_REVIEW', true);

                        // Revisão em background → cauda externa SÍNCRONA (não adiar, sem pico aqui).
                        if (!empty($result['deferred_external'])) {
                            try {
                                $__d = $result['deferred_external'];
                                (new \services\OrderPaymentService($pdo))->runExternalEffects($__d['order'], $__d['campaign'] ?? [], $__d['user'] ?? null);
                            } catch (\Throwable $__te) { error_log('[revisão] efeitos externos ignorados: ' . $__te->getMessage()); }
                        }

                        if ($result['success']) {
                            $processed++;
                            adminLog('payment_reviewed', [
                                'order_hash' => $order['hash_order'],
                                'order_id' => $order['id'],
                                'txid' => $order['txid'],
                                'campaign_id' => $campaignId,
                                'gateway' => $orderGateway,
                                'source' => 'api_check'
                            ]);
                        } else {
                            $failed++;
                            $errors[] = [
                                'order_id' => $order['id'],
                                'hash_order' => $order['hash_order'],
                                'error' => $result['message'] ?? 'Erro ao processar pagamento confirmado via ' . strtoupper($orderGateway)
                            ];
                        }
                    }
                } catch (\Exception $e) {
                    error_log("[REVIEW-PAYMENTS] Erro ao consultar " . strtoupper($orderGateway) . " para txid {$order['txid']}: " . $e->getMessage());
                }
            }
        }

        // Mensagem simples e direta
        if ($checkedApi === 0) {
            $message = 'Nenhuma compra expirada encontrada.';
        } elseif ($processed > 0) {
            $webhookMsg = $confirmedByWebhook > 0 ? " ($confirmedByWebhook via webhook)" : "";
            $message = "$checkedApi compras revisadas. $processed confirmada(s) como paga(s)$webhookMsg.";
        } else {
            $message = "$checkedApi compras expiradas verificadas. Nenhum pagamento confirmado encontrado.";
        }

        echo json_encode([
            'success' => true,
            'count' => $checkedApi,
            'processed' => $processed,
            'failed' => $failed,
            'confirmed_by_webhook' => $confirmedByWebhook,
            'confirmed_by_api' => $paidViaApi,
            'message' => $message
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/locations$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/locations - Estatísticas de localização dos compradores
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];
        $orderModel = new \models\Order($pdo);

        // Busca estatísticas de localização
        $locationStats = $orderModel->getLocationStatsByCampaign($campaignId);

        echo json_encode([
            'success' => true,
            'data' => $locationStats
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/sales-report$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/sales-report - Download CSV de vendas
        $authMiddleware->handle();
        $_GET['campaign_id'] = $matches[1];
        $ordersController->exportSalesReport();
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/participants-report$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/participants-report - Download CSV de participantes
        $authMiddleware->handle();
        $_GET['campaign_id'] = $matches[1];
        $ordersController->exportParticipantsReport();
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/participants-list$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/participants-list - JSON para gerar PDF no frontend
        $authMiddleware->handle();
        header('Content-Type: application/json; charset=utf-8');

        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $stmt = $pdo->prepare("
            SELECT o.numbers, u.name
            FROM orders o
            LEFT JOIN users u ON o.id_user = u.id
            WHERE o.id_campaign = ? AND o.payment_status = 'pago'
            ORDER BY o.created_at ASC
        ");
        $stmt->execute([$campaign['id']]);

        $participants = [];
        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
            $numbers = trim($row['numbers'] ?? '');
            if (empty($numbers)) continue;
            $numberList = explode(',', $numbers);
            $name = mb_strtoupper(trim($row['name'] ?? ''), 'UTF-8');
            foreach ($numberList as $num) {
                $num = trim($num);
                if ($num === '') continue;
                $participants[] = [
                    'number' => str_pad($num, 2, '0', STR_PAD_LEFT),
                    'name' => $name,
                    'status' => 'PAGO'
                ];
            }
        }

        // Ordena por número
        usort($participants, function($a, $b) {
            return (int)$a['number'] - (int)$b['number'];
        });

        echo json_encode([
            'success' => true,
            'campaign' => [
                'name' => $campaign['name'],
                'hash_id' => $campaign['hash_id']
            ],
            'participants' => $participants
        ], JSON_UNESCAPED_UNICODE);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/utm-links$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/utm-links - Lista UTMs com views por campanha
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignSlug = $campaign['slug'] ?? '';
        $utmModel = new \models\Utm($pdo);

        // Busca todos os UTMs com views para esta campanha
        $utmLinks = $utmModel->getAllWithViewsByCampaign($campaignSlug);

        echo json_encode([
            'success' => true,
            'data' => $utmLinks
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/daily-sales$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/daily-sales - Vendas diárias por UTM
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $orderModel = new \models\Order($pdo);
        $dailySales = $orderModel->getDailySalesByUtm($campaign['id']);

        echo json_encode([
            'success' => true,
            'data' => $dailySales
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/total-sales$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/total-sales - Vendas totais por UTM
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $orderModel = new \models\Order($pdo);
        $totalSales = $orderModel->getTotalSalesByUtm($campaign['id']);

        echo json_encode([
            'success' => true,
            'data' => $totalSales
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/winner-stats$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/winner-stats - Estatísticas dos ganhadores
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        // Parse winners from campaign.winner field
        $winnerStats = [];
        if (!empty($campaign['winner'])) {
            $winnerField = $campaign['winner'];
            $winnerSets = explode('},{', $winnerField);

            foreach ($winnerSets as $index => $set) {
                $cleaned = trim($set, '{}');
                $values = explode(',', $cleaned);
                $phone = isset($values[2]) ? preg_replace('/[^0-9]/', '', $values[2]) : '';

                if (!empty($phone)) {
                    // Pegar os últimos 8 dígitos para comparação mais flexível
                    $phoneEnd = substr($phone, -8);

                    // Buscar total de compras e títulos deste telefone nesta campanha
                    // O telefone está na tabela users, não em orders
                    // total é varchar no PostgreSQL, precisa de cast para NUMERIC
                    $stmt = $pdo->prepare("
                        SELECT
                            COALESCE(SUM(CAST(REPLACE(o.total, ',', '.') AS NUMERIC)), 0) as total_purchases,
                            COALESCE(SUM(o.quantity), 0) as total_titles
                        FROM orders o
                        INNER JOIN users u ON o.id_user = u.id
                        WHERE o.id_campaign = ?
                        AND REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(u.phone, '(', ''), ')', ''), '-', ''), ' ', ''), '+55', '') LIKE ?
                        AND o.payment_status = 'pago'
                    ");
                    $stmt->execute([$campaign['id'], '%' . $phoneEnd]);
                    $stats = $stmt->fetch(PDO::FETCH_ASSOC);

                    // Buscar hash_id do usuário separadamente
                    $stmtUser = $pdo->prepare("
                        SELECT hash_id
                        FROM users
                        WHERE REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(phone, '(', ''), ')', ''), '-', ''), ' ', ''), '+55', '') LIKE ?
                        LIMIT 1
                    ");
                    $stmtUser->execute(['%' . $phoneEnd]);
                    $userHashId = $stmtUser->fetchColumn();

                    // Buscar a order que contém o título ganhador
                    $winnerNumber = isset($values[1]) ? trim($values[1]) : '';
                    $orderHashId = null;
                    if (!empty($winnerNumber)) {
                        // PostgreSQL: usar ANY com string_to_array, comparando como inteiros (ignora zeros à esquerda)
                        $stmtOrder = $pdo->prepare("
                            SELECT hash_order
                            FROM orders
                            WHERE id_campaign = ?
                            AND payment_status = 'pago'
                            AND ?::bigint = ANY(ARRAY(SELECT unnest(string_to_array(REPLACE(numbers, ', ', ','), ','))::bigint))
                            LIMIT 1
                        ");
                        $stmtOrder->execute([$campaign['id'], $winnerNumber]);
                        $orderHashId = $stmtOrder->fetchColumn();
                    }

                    $winnerStats[$index] = [
                        'total_purchases' => (float)($stats['total_purchases'] ?? 0),
                        'total_titles' => (int)($stats['total_titles'] ?? 0),
                        'user_hash_id' => $userHashId ?: null,
                        'order_hash_id' => $orderHashId ?: null,
                    ];
                }
            }
        }

        echo json_encode([
            'success' => true,
            'data' => $winnerStats
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/winner-stats-by-phone$/', $path, $matches)) {
        // API Route: GET /api/campaigns/{hash_id}/winner-stats-by-phone?phone=...
        // Agrega total de títulos e valor em compras de um telefone nesta campanha. Usado pelo
        // detalhe do ganhador das dinâmicas instantâneas (caixa/roleta/raspadinha/chute/título),
        // onde o ganhador não está salvo em campaign.winner mas temos o telefone dele no item.
        // Mesma lógica de agregação por telefone (últimos 8 dígitos) do endpoint winner-stats.
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $phone = preg_replace('/[^0-9]/', '', $_GET['phone'] ?? '');
        if (empty($phone)) {
            echo json_encode(['success' => true, 'data' => ['total_titles' => 0, 'total_purchases' => 0]]);
            exit();
        }

        $phoneEnd = substr($phone, -8);
        $pdo = $campaignModel->getPdo();
        $stmt = $pdo->prepare("
            SELECT
                COALESCE(SUM(CAST(REPLACE(o.total, ',', '.') AS NUMERIC)), 0) as total_purchases,
                COALESCE(SUM(o.quantity), 0) as total_titles
            FROM orders o
            INNER JOIN users u ON o.id_user = u.id
            WHERE o.id_campaign = ?
            AND REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(u.phone, '(', ''), ')', ''), '-', ''), ' ', ''), '+55', '') LIKE ?
            AND o.payment_status = 'pago'
        ");
        $stmt->execute([$campaign['id'], '%' . $phoneEnd]);
        $stats = $stmt->fetch(PDO::FETCH_ASSOC);

        echo json_encode([
            'success' => true,
            'data' => [
                'total_purchases' => (float)($stats['total_purchases'] ?? 0),
                'total_titles' => (int)($stats['total_titles'] ?? 0),
            ],
        ]);
        exit();
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/winner-video$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] === 'POST') {
        // API Route: POST /api/campaigns/{hash_id}/winner-video - Salvar vídeo do ganhador
        header('Content-Type: application/json; charset=utf-8');
        $authMiddleware->handle();
        $campaignHashId = $matches[1];
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);

        if (!$campaign) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $data = json_decode(file_get_contents('php://input'), true);
        $winnerIndex = isset($data['winnerIndex']) ? (int)$data['winnerIndex'] : -1;
        $videoUrl = isset($data['videoUrl']) ? trim($data['videoUrl']) : '';

        if ($winnerIndex < 0) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Índice do ganhador inválido']);
            exit();
        }

        // Parse winner field and update the video URL at the specified index
        $winnerField = $campaign['winner'] ?? '';
        if (empty($winnerField)) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Nenhum ganhador cadastrado']);
            exit();
        }

        // Split by newlines
        $winnerLines = explode("\n", $winnerField);

        if (!isset($winnerLines[$winnerIndex])) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Ganhador não encontrado no índice especificado']);
            exit();
        }

        // Parse the winner line and update position 7 (video URL)
        $line = $winnerLines[$winnerIndex];
        $parts = explode(',', $line);

        // Ensure we have at least 8 parts (index 0-7)
        while (count($parts) < 8) {
            $parts[] = '';
        }

        // Update video URL at position 7
        $parts[7] = $videoUrl;

        // Reconstruct the line
        $winnerLines[$winnerIndex] = implode(',', $parts);

        // Reconstruct the full winner field
        $newWinnerField = implode("\n", $winnerLines);

        // Update in database
        $stmt = $pdo->prepare("UPDATE campaigns SET winner = ? WHERE id = ?");
        $result = $stmt->execute([$newWinnerField, $campaign['id']]);

        if ($result) {
            echo json_encode(['success' => true, 'message' => 'Vídeo salvo com sucesso']);
        } else {
            http_response_code(500);
            echo json_encode(['success' => false, 'message' => 'Erro ao salvar vídeo']);
        }
        exit();
    // ROTAS REMOVIDAS: /dashboard/campaigns/{hash}/dashboard, /dashboard/orders,
    // /dashboard/roulette, /dashboard/boxes
    // Substituídas pelo frontend Next.js (frontend-prod)
    } elseif ($path == '/api/register' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        // Rate limiting: 5 cadastros por minuto por IP
        if (!checkRateLimit($pdo, 'register', 5, 60)) {
            securityLog('rate_limit_exceeded', ['endpoint' => '/api/register']);
            respondRateLimitExceeded();
        }

        // API pública para registro de usuário
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        try {
            $input = json_decode(file_get_contents('php://input'), true);

            $name = htmlspecialchars($input['name'] ?? '', ENT_QUOTES, 'UTF-8');
            $email = !empty($input['email']) ? htmlspecialchars($input['email'], ENT_QUOTES, 'UTF-8') : null;
            $phone = preg_replace('/\D/', '', $input['phone'] ?? '');
            $phoneCountry = !empty($input['phone_country']) ? strtoupper($input['phone_country']) : 'BR';
            $cpf = !empty($input['cpf']) ? preg_replace('/\D/', '', $input['cpf']) : null;
            $birthDate = !empty($input['birth_date']) ? htmlspecialchars($input['birth_date'], ENT_QUOTES, 'UTF-8') : null;
            $campaignId = !empty($input['campaign_id']) ? intval($input['campaign_id']) : null;
            // Nome social — coluna `username`. O formulario de cadastro ja coletava, mas
            // NINGUEM lia: o dado era descartado e a tela seguinte pedia de novo.
            $socialName = !empty($input['social_name']) ? htmlspecialchars($input['social_name'], ENT_QUOTES, 'UTF-8') : '';

            // Validar idade mínima se campanha exige data de nascimento
            if ($campaignId && !empty($birthDate)) {
                $campaignForAge = $campaignModel->getCampaignById($campaignId);
                if ($campaignForAge && filter_var($campaignForAge['required_birth_date'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
                    $minAge = intval($settings['min_age'] ?? 18);
                    $birthCheck = validateBirthDate($birthDate, $minAge);
                    if (!$birthCheck['valid']) {
                        echo json_encode(['success' => false, 'message' => $birthCheck['message']]);
                        exit();
                    }
                }
            }

            if (empty($name)) {
                echo json_encode(['success' => false, 'message' => 'Nome é obrigatório']);
                exit();
            }

            if (empty($phone) && empty($cpf)) {
                echo json_encode(['success' => false, 'message' => 'Telefone ou CPF é obrigatório']);
                exit();
            }

            // Validar telefone se informado
            if (!empty($phone)) {
                $phoneValidation = validatePhone($phone, $phoneCountry);
                if (!$phoneValidation['valid']) {
                    echo json_encode(['success' => false, 'message' => $phoneValidation['message']]);
                    exit();
                }
                $phone = $phoneValidation['cleaned']; // Usar telefone limpo
            }

            // Validar CPF se informado
            if (!empty($cpf)) {
                $cpfValidation = validateCpf($cpf);
                if (!$cpfValidation['valid']) {
                    echo json_encode(['success' => false, 'message' => $cpfValidation['message']]);
                    exit();
                }
            }

            // Formatar telefone
            if (strlen($phone) == 11) {
                $phoneFormatted = '(' . substr($phone, 0, 2) . ') ' . substr($phone, 2, 5) . '-' . substr($phone, 7, 4);
            } else {
                $phoneFormatted = $phone;
            }

            $userModel = new \models\User($pdo);

            // Telefone já cadastrado: COMPLETA o cadastro existente em vez de recusar.
            //
            // Por que: quando o cliente troca o login de telefone para CPF, todo comprador
            // antigo (que tem telefone e nome, mas não tem CPF) caía num beco sem saída —
            // buscava pelo CPF, ouvia "não encontrado, registre-se", preenchia o cadastro com
            // o próprio telefone e levava "Este telefone já está cadastrado". Não entrava nem
            // se cadastrava: não comprava.
            //
            // Só completa quando o cadastro existente NÃO tem CPF (ou tem o mesmo). Se tiver
            // outro CPF, é conflito de verdade e continua recusando.
            //
            // Preenche apenas o que está VAZIO no cadastro atual — nome e e-mail já existentes
            // não são sobrescritos pelo que foi digitado agora. Completar é seguro; trocar o
            // dado de alguém em silêncio, não.
            //
            // ⚠️ LIMITE CONHECIDO: o login do sistema é SEM SENHA. Em modo CPF isso significa
            // que quem souber o telefone de outra pessoa consegue vincular o próprio CPF à
            // conta dela e passar a entrar por CPF. Fechar isso exige verificação do telefone
            // (código por WhatsApp/SMS), que é outro projeto. Decidido com o João em 10/09/2026.
            if (!empty($phone)) {
                $phoneOwner = $purchaseController->checkPhone($phone);

                if ($phoneOwner) {
                    $cpfDoDono = preg_replace('/\D/', '', $phoneOwner['cpf'] ?? '');

                    if (!empty($cpfDoDono) && $cpfDoDono !== $cpf) {
                        echo json_encode(['success' => false, 'message' => 'Este telefone já está cadastrado']);
                        exit();
                    }

                    // O CPF informado não pode estar em uso por OUTRA conta.
                    if (!empty($cpf)) {
                        $cpfOwner = $userModel->getUserByCpf($cpf);
                        if ($cpfOwner && (int)$cpfOwner['id'] !== (int)$phoneOwner['id']) {
                            echo json_encode(['success' => false, 'message' => 'Este CPF já está cadastrado']);
                            exit();
                        }
                    }

                    // Idem para o e-mail.
                    if (!empty($email)) {
                        $emailOwner = $userModel->getUserByEmail($email);
                        if ($emailOwner && (int)$emailOwner['id'] !== (int)$phoneOwner['id']) {
                            echo json_encode(['success' => false, 'message' => 'Este e-mail já está cadastrado']);
                            exit();
                        }
                    }

                    $completar = [];
                    if (!empty($cpf) && empty($cpfDoDono)) $completar['cpf'] = $cpf;
                    if (!empty($email) && empty($phoneOwner['email'])) $completar['email'] = $email;
                    if (!empty($name) && empty($phoneOwner['name'])) $completar['name'] = $name;
                    // `age` é a coluna da data de nascimento (a `birth_date` é legada e vazia).
                    if (!empty($birthDate) && empty($phoneOwner['age'])) $completar['age'] = $birthDate;
                    if (!empty($socialName) && empty($phoneOwner['username'])) $completar['username'] = $socialName;

                    if (!empty($completar)) {
                        $userModel->updateUserFields($phoneOwner['id'], $completar);
                    }

                    $atualizado = $userModel->getUserById($phoneOwner['id']);
                    $phoneCleanRet = preg_replace('/\D/', '', $atualizado['phone'] ?? '');

                    echo json_encode([
                        'success' => true,
                        // Diz ao front que ISTO NÃO FOI UM CADASTRO NOVO: um já existia com este
                        // telefone e foi completado. Sem esta flag ele não teria como distinguir
                        // os dois casos — e a pessoa veria o nome ANTIGO na tela, sem entender
                        // por que o que ela digitou não valeu.
                        'updated' => true,
                        'data' => [
                            'id' => $atualizado['id'],
                            'name' => $atualizado['name'],
                            'phone' => $phoneCleanRet,
                            'phone_country' => $atualizado['phone_country'] ?? 'BR',
                            'cpf' => $atualizado['cpf'] ?? '',
                            'birth_date' => $atualizado['age'] ?? '',
                            'email' => $atualizado['email'] ?? '',
                            'social_name' => $atualizado['username'] ?? ''
                        ]
                    ]);
                    exit();
                }
            }

            // Validar CPF único - se já existe, bloqueia
            if (!empty($cpf)) {
                $cpfOwner = $userModel->getUserByCpf($cpf);
                if ($cpfOwner) {
                    echo json_encode(['success' => false, 'message' => 'Este CPF já está cadastrado']);
                    exit();
                }
            }

            // Validar email único - se já existe, bloqueia
            if (!empty($email)) {
                $emailOwner = $userModel->getUserByEmail($email);
                if ($emailOwner) {
                    echo json_encode(['success' => false, 'message' => 'Este e-mail já está cadastrado']);
                    exit();
                }
            }

            // Criar usuário novo. created_at/updated_at vêm do model via NOW() (UTC). [fix P2]
            $userModel->createUser($name, $socialName, $email, '', $phoneFormatted, $cpf, $birthDate, '', 'user-image.png', 'subscriber', null, null, $phoneCountry);

            $newUser = $userModel->getUserByPhone($phoneFormatted);

            if ($newUser) {
                // Dispara o webhook user.create (novo participante). Envolto em try/catch
                // próprio: uma falha no webhook não pode derrubar o cadastro.
                try {
                    \model\Webhook::triggerUserCreate($pdo, $newUser);
                } catch (\Throwable $e) {
                    error_log('[Webhook] user.create falhou: ' . $e->getMessage());
                }

                // Limpar telefone para retorno consistente
                $phoneClean = preg_replace('/\D/', '', $newUser['phone'] ?? '');

                echo json_encode([
                    'success' => true,
                    'data' => [
                        'id' => $newUser['id'],
                        'name' => $newUser['name'],
                        'phone' => $phoneClean,
                        'phone_country' => $newUser['phone_country'] ?? 'BR',
                        'cpf' => $newUser['cpf'] ?? '',
                        'birth_date' => $newUser['age'] ?? '',
                        'email' => $newUser['email'] ?? '',
                        'social_name' => $newUser['username'] ?? ''
                    ]
                ]);
            } else {
                echo json_encode(['success' => false, 'message' => 'Erro ao criar usuário']);
            }
        } catch (Exception $e) {
            http_response_code(500);
            error_log('[API] Erro: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/checkout' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $purchaseController->checkout();
    } elseif ($path == '/api/public/theme' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // API pública para buscar configurações de tema (sem autenticação)
        // Usada pelo frontend Next.js para aplicar tema antes do login
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        try {
            $currentSettings = $settingsController->getSettings();
            echo json_encode([
                'success' => true,
                'data' => [
                    'theme' => (int)($currentSettings['theme'] ?? 0),
                    'color_website' => $currentSettings['color_website'] ?? '#00AB55',
                    'image_logo' => $currentSettings['image_logo'] ?? ''
                ]
            ], JSON_UNESCAPED_UNICODE);
        } catch (Exception $e) {
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error' => 'Erro ao buscar configurações de tema'
            ], JSON_UNESCAPED_UNICODE);
        }
        exit();
    } elseif ($path == '/api/public/track-visit' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        // Rate limiting: 30 req/min por IP (protecao anti-flood)
        if (!checkRateLimit($pdo, 'track-visit', 30, 60)) { respondRateLimitExceeded(); }
        // API pública para contar visitas do frontend Next.js.
        // ENXUTO: só conta (+1 no visit_daily). NÃO grava IP/navegador/detalhe
        // (sem PII, sem LGPD, sem tabela crescendo). A dedup "1x por dia por
        // visitante" é feita no frontend por cookie — se esta rota foi chamada,
        // é uma visita a contar.
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        try {
            $data = json_decode(file_get_contents('php://input'), true);
            $campaignSlug = isset($data['campaign_slug']) ? substr($data['campaign_slug'], 0, 150) : null;

            // Incrementa contadores agregados (visit_daily). Data em horario de Brasilia.
            // Best-effort: se a tabela nao existir (cliente sem migration), nao quebra.
            try {
                // Contador GERAL do dia (campaign_slug = '')
                $pdo->prepare("
                    INSERT INTO visit_daily (visit_date, campaign_slug, total)
                    VALUES ((NOW() AT TIME ZONE 'America/Sao_Paulo')::date, '', 1)
                    ON CONFLICT (visit_date, campaign_slug)
                    DO UPDATE SET total = visit_daily.total + 1
                ")->execute();
                // Contador por CAMPANHA (se a visita tem slug)
                if (!empty($campaignSlug)) {
                    $stmtC = $pdo->prepare("
                        INSERT INTO visit_daily (visit_date, campaign_slug, total)
                        VALUES ((NOW() AT TIME ZONE 'America/Sao_Paulo')::date, ?, 1)
                        ON CONFLICT (visit_date, campaign_slug)
                        DO UPDATE SET total = visit_daily.total + 1
                    ");
                    $stmtC->execute([$campaignSlug]);
                }
            } catch (Exception $eCounter) {
                error_log('[visit_daily] contador falhou (nao critico): ' . $eCounter->getMessage());
            }

            echo json_encode(['success' => true, 'tracked' => true, 'message' => 'Visita contada']);
        } catch (Exception $e) {
            http_response_code(500);
            error_log('[API] Erro ao contar visita: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/public/affiliate/hit' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        // Rate limiting: 30 req/min por IP
        if (!checkRateLimit($pdo, 'affiliate-hit', 30, 60)) { respondRateLimitExceeded(); }
        // API pública para registrar hit (visualização) de link de afiliado
        // Chamada pelo frontend Next.js quando usuário acessa via ?afiliado=CODE
        header('Content-Type: application/json; charset=utf-8');
        header('Access-Control-Allow-Methods: POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        try {
            $data = json_decode(file_get_contents('php://input'), true);

            $code = $data['code'] ?? null;
            $campaignSlug = $data['campaign_slug'] ?? null;

            if (!$code || strlen($code) !== 10) {
                echo json_encode(['success' => false, 'message' => 'Código de afiliado inválido']);
                exit();
            }

            // Incrementa hits do afiliado
            $affiliateController->incrementAffiliateHits($code, $campaignSlug);

            echo json_encode(['success' => true, 'message' => 'Hit registrado']);
        } catch (Exception $e) {
            http_response_code(500);
            error_log('[API] Erro ao registrar hit: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/public/coupon/validate' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // API pública: valida um cupom (?cupom=) e devolve o TIPO + dados pro front travar a compra.
        // Fonte da verdade é o backend (mesma query de validação do checkout). O front só reflete;
        // o preço final é sempre re-travado no /api/checkout. Ver docs/2026-08-14_FEATURE_CUPONS.md.
        if (!checkRateLimit($pdo, 'coupon-validate', 60, 60)) { respondRateLimitExceeded(); }
        header('Content-Type: application/json; charset=utf-8');
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        $campaignRef = $_GET['campaign'] ?? null; // slug ou hash_id
        $code = trim((string) ($_GET['code'] ?? ''));
        if (empty($campaignRef) || $code === '') {
            echo json_encode(['success' => true, 'valid' => false]);
            exit();
        }

        // Resolve a campanha por slug OU hash_id (o front pode mandar qualquer um).
        $stmtCamp = $pdo->prepare('SELECT id FROM campaigns WHERE slug = ? OR hash_id = ? LIMIT 1');
        $stmtCamp->execute([$campaignRef, $campaignRef]);
        $camp = $stmtCamp->fetch(\PDO::FETCH_ASSOC);
        if (!$camp) {
            echo json_encode(['success' => true, 'valid' => false]);
            exit();
        }

        // Mesma regra do checkout, numa fonte só (helpers/coupon_helper.php). Devolve o MOTIVO
        // e a mensagem pro front dizer "atingiu o limite" / "expirou" em vez do genérico.
        // O limite por usuário não entra aqui (comprador ainda não identificado) — só no checkout.
        $resolved = resolveCoupon($pdo, (int) $camp['id'], $code);
        $coupon = $resolved['coupon'];
        if ($resolved['reason'] !== 'ok') {
            echo json_encode([
                'success' => true,
                'valid'   => false,
                'reason'  => $resolved['reason'],
                'message' => couponReasonMessage($resolved['reason'], $coupon['code'] ?? $code),
            ], JSON_UNESCAPED_UNICODE);
            exit();
        }
        $type = $coupon['checkout_type'] ?? 'custom';

        echo json_encode([
            'success'          => true,
            'valid'            => true,
            'code'             => $coupon['code'],
            'checkout_type'    => $type,
            'discount_percent' => $coupon['discount_percent'] !== null ? (float) $coupon['discount_percent'] : null,
            'minimum_quantity' => $coupon['minimum_quantity'] !== null ? (int) $coupon['minimum_quantity'] : 0,
            'fixed_numbers'    => $coupon['fixed_numbers'] !== null ? (int) $coupon['fixed_numbers'] : null,
            'fixed_price'      => $coupon['fixed_price'] !== null ? (float) $coupon['fixed_price'] : null,
        ], JSON_UNESCAPED_UNICODE);
        exit();
    } elseif (preg_match('#^/api/public/campaigns/([a-zA-Z0-9]+)/top-buyers$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicController.php';
        (new \controller\PublicController($pdo))->topBuyers($matches[1]);

    // ==================== PUBLIC BIGGER/SMALLER (Menor/Maior Título Público) ====================
    // API pública - Schedule destacado com resultado atual
    } elseif (preg_match('#^/api/public/campaigns/([a-zA-Z0-9-]+)/highlighted-schedule$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicController.php';
        (new \controller\PublicController($pdo))->highlightedSchedule($matches[1]);

    } elseif (preg_match('#^/api/public/campaigns/([a-zA-Z0-9-]+)/bigger-smaller$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicController.php';
        (new \controller\PublicController($pdo))->biggerSmaller($matches[1]);

    } elseif (preg_match('#^/api/public/campaigns/([a-zA-Z0-9-]+)/buyer-schedules$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicController.php';
        (new \controller\PublicController($pdo))->buyerSchedules($matches[1]);

    } elseif (preg_match('#^/api/public/campaigns/([a-f0-9]{24})/numbers-status$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicController.php';
        (new \controller\PublicController($pdo))->numbersStatus($matches[1]);
    } elseif ($path == '/api/public/campaigns' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // Rate limiting: 60 req/min por IP
        if (!checkRateLimit($pdo, 'public-campaigns', 60, 60)) { respondRateLimitExceeded(); }
        // API pública para listar campanhas (sem autenticação)
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        // Paleta de cores do sistema (mesma do header.php)
        $colorPalette = [
            '#00AB55' => ['dark' => '#007B55', 'light' => '#5BE584', 'site' => '#198754', 'contrast' => '#FFFFFF'],
            '#198754' => ['dark' => '#146c43', 'light' => '#75b798', 'site' => '#198754', 'contrast' => '#FFFFFF'],
            '#7635DC' => ['dark' => '#5119B7', 'light' => '#C684FF', 'site' => '#7635DC', 'contrast' => '#FFFFFF'],
            '#00B8D9' => ['dark' => '#006C9C', 'light' => '#61F3F3', 'site' => '#00B8D9', 'contrast' => '#212B36'],
            '#0C68E9' => ['dark' => '#0850B4', 'light' => '#69A0FF', 'site' => '#0C68E9', 'contrast' => '#FFFFFF'],
            '#FDA92D' => ['dark' => '#B76E00', 'light' => '#FFD666', 'site' => '#FDA92D', 'contrast' => '#212B36'],
            '#FF5630' => ['dark' => '#B71D18', 'light' => '#FFAC82', 'site' => '#FF5630', 'contrast' => '#FFFFFF'],
            '#3F51B5' => ['dark' => '#2A3990', 'light' => '#8C9EFF', 'site' => '#3F51B5', 'contrast' => '#FFFFFF'],
            '#009688' => ['dark' => '#00695C', 'light' => '#64FFDA', 'site' => '#009688', 'contrast' => '#FFFFFF'],
            '#84CC16' => ['dark' => '#5C8A00', 'light' => '#C6FF00', 'site' => '#84CC16', 'contrast' => '#212B36'],
            '#FFD600' => ['dark' => '#C7A500', 'light' => '#FFFF00', 'site' => '#FFD600', 'contrast' => '#212B36'],
            '#E91E63' => ['dark' => '#AD1457', 'light' => '#FF80AB', 'site' => '#E91E63', 'contrast' => '#FFFFFF'],
            '#795548' => ['dark' => '#4E342E', 'light' => '#A1887F', 'site' => '#795548', 'contrast' => '#FFFFFF'],
            '#607D8B' => ['dark' => '#37474F', 'light' => '#90A4AE', 'site' => '#607D8B', 'contrast' => '#FFFFFF'],
            '#FF5722' => ['dark' => '#BF360C', 'light' => '#FFAB91', 'site' => '#FF5722', 'contrast' => '#FFFFFF'],
            '#673AB7' => ['dark' => '#4527A0', 'light' => '#B388FF', 'site' => '#673AB7', 'contrast' => '#FFFFFF'],
            '#546E7A' => ['dark' => '#37474F', 'light' => '#90A4AE', 'site' => '#546E7A', 'contrast' => '#FFFFFF'],
            '#FFC107' => ['dark' => '#FF8F00', 'light' => '#FFE57F', 'site' => '#FFC107', 'contrast' => '#212B36'],
            '#03A9F4' => ['dark' => '#0277BD', 'light' => '#81D4FA', 'site' => '#03A9F4', 'contrast' => '#FFFFFF'],
        ];

        try {
            $campaignModel = new \models\Campaign($pdo);
            // Este endpoint serve DUAS páginas com a mesma URL, distinguidas pelo ?fields=minimal:
            // - HOME (page.jsx usa ?fields=minimal): a VITRINE da página inicial → getHomeCampaigns()
            //   respeita o toggle "Mostrar na página inicial" (show_home).
            // - /campanhas (sem o param): a listagem GERAL de campanhas → getPublicCampaigns(), que
            //   mostra TODAS as públicas (o show_home NÃO se aplica aqui — só controla a home).
            // Ambas filtram privacy + status no SQL (privada nunca vaza em nenhuma das duas).
            $isHomeVitrine = (($_GET['fields'] ?? '') === 'minimal');
            $allCampaigns = $isHomeVitrine
                ? $campaignModel->getHomeCampaigns()
                : $campaignModel->getPublicCampaigns();

            // Pegar cor global do settings
            $currentSettings = $settingsController->getSettings();
            $colorWebsite = strtoupper($currentSettings['color_website'] ?? '#00AB55');

            $campaigns = [];
            foreach ($allCampaigns as $campaign) {
                    // Salva ID antes de remover campos sensíveis
                    $campaignId = $campaign['id'];
                    // Guarda config da barra ANTES do removeSensitiveFields (ele remove esses campos
                    // pra não vazar que a barra é manipulada). maskSalesStatistics precisa deles pra
                    // calcular a % exibida — re-injetamos antes do mask; a resposta final fica SEM eles.
                    $campaignPctApplied       = $campaign['percentage_applied'] ?? null;
                    $campaignPctAppliedActive = $campaign['percentage_applied_active'] ?? null;
                    $campaignProgressBarPct   = $campaign['progress_bar_percent'] ?? null;
                    $campaignProgressBarAdj   = $campaign['progress_bar_adjustable'] ?? null;

                    // Remover TODOS os campos sensíveis (credenciais, config interna, etc)
                    removeSensitiveFields($campaign);

                    // Restaura id pra usar nas queries de combos/ganhadores
                    $campaign['id'] = $campaignId;

                    // Adicionar cores da paleta baseado na cor global do settings
                    if (isset($colorPalette[$colorWebsite])) {
                        $campaign['color_light'] = $colorPalette[$colorWebsite]['light'];
                        $campaign['color_dark'] = $colorPalette[$colorWebsite]['dark'];
                        $campaign['color_site'] = $colorPalette[$colorWebsite]['site'];
                        $campaign['color_contrast'] = $colorPalette[$colorWebsite]['contrast'];
                    } else {
                        $campaign['color_light'] = null;
                        $campaign['color_dark'] = null;
                        $campaign['color_site'] = $colorWebsite;
                        $campaign['color_contrast'] = null;
                    }

                    // Carrega combos, ganhadores e títulos premiados
                    loadCombosWinnersAndTitles($pdo, $campaign, $campaignModel);

                    // Usar sold_numbers e qtd_numbers da tabela campaigns (evita COUNT em 17M+ linhas)
                    $soldCount = (int)($campaign['sold_numbers'] ?? $campaign['total_numbers_sold'] ?? 0);
                    $qtdTotal = (int)($campaign['qtd_numbers'] ?? 0);
                    $campaign['sold_numbers_count'] = $soldCount;
                    $campaign['available_numbers_count'] = max(0, $qtdTotal - $soldCount - (int)($campaign['pending_numbers'] ?? 0));

                    // Calcular percentage_sold corretamente
                    $qtdNumbers = (int)($campaign['qtd_numbers'] ?? 0);
                    $soldCount = (int)($campaign['sold_numbers_count'] ?? 0);
                    $campaign['percentage_sold'] = $qtdNumbers > 0
                        ? round(($soldCount / $qtdNumbers) * 100, 2)
                        : 0;

                    // Verificar se esgotou: sold + pending >= total (sem livres)
                    $pendingCount = (int)($campaign['pending_numbers'] ?? 0);
                    $campaign['is_sold_out'] = ($soldCount + $pendingCount >= $qtdNumbers);

                    // Re-injeta a config da barra (removida acima) só pro cálculo do mask
                    $campaign['percentage_applied']        = $campaignPctApplied;
                    $campaign['percentage_applied_active'] = $campaignPctAppliedActive;
                    $campaign['progress_bar_percent']      = $campaignProgressBarPct;
                    $campaign['progress_bar_adjustable']   = $campaignProgressBarAdj;

                    // Mascarar estatísticas e remover campos sensíveis (API pública)
                    maskSalesStatistics($campaign, true);

                    // Remove de novo a config da barra — NÃO pode vazar na API (revelaria a manipulação)
                    unset($campaign['percentage_applied'], $campaign['percentage_applied_active'],
                          $campaign['progress_bar_percent'], $campaign['progress_bar_adjustable']);

                    // Montar array de imagens para o carrossel (image_raffle + image_raffle_galery)
                    $allImages = [];
                    if (!empty($campaign['image_raffle'])) {
                        $allImages[] = $campaign['image_raffle'];
                    }
                    if (!empty($campaign['image_raffle_galery'])) {
                        $galleryImages = array_filter(array_map('trim', explode(',', $campaign['image_raffle_galery'])));
                        $allImages = array_merge($allImages, $galleryImages);
                    }
                    $campaign['images'] = $allImages;

                    // Parse promos JSON para enviar como array ao frontend
                    if (!empty($campaign['promos'])) {
                        $campaign['promos'] = json_decode($campaign['promos'], true) ?: [];
                    } else {
                        $campaign['promos'] = [];
                    }

                    // Parse upsell_values JSON
                    if (!empty($campaign['upsell_values'])) {
                        $campaign['upsell_values'] = json_decode($campaign['upsell_values'], true) ?: [];
                    } else {
                        $campaign['upsell_values'] = [];
                    }

                    // Aplicar qtd_max dinâmico se configurado
                    $dynamicInterval = (int)($campaign['dynamic_max_interval'] ?? 0);
                    if ($dynamicInterval > 0) {
                        $campaign['qtd_max'] = calculateDynamicQtdMax(
                            (int)$campaign['qtd_max'],
                            $dynamicInterval,
                            $campaignId,
                            (int)($campaign['qtd_min'] ?? 1)
                        );
                    }
                    unset($campaign['dynamic_max_interval']);

                    // Remove id numérico restaurado (só era necessário para queries internas)
                    unset($campaign['id']);

                    // Boolean: 't'/'f' cru → true/false real (ver docs/PLANO_PADRONIZACAO_BOOLEAN_APIS.md)
                    normalizeBooleans($campaign, CAMPAIGN_BOOL_FIELDS);

                    $campaigns[] = $campaign;
            }

            echo json_encode([
                'success' => true,
                'data' => $campaigns
            ], JSON_UNESCAPED_UNICODE);
        } catch (Exception $e) {
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error' => 'Erro ao buscar campanhas'
            ], JSON_UNESCAPED_UNICODE);
        }
        exit();
    } elseif (preg_match('#^/api/public/campaign/([a-zA-Z0-9_-]+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicController.php';
        (new \controller\PublicController($pdo))->campaign($matches[1]);
    } elseif ($path == '/api/public/faqs' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/FaqController.php';
        (new \controller\FaqController($pdo))->publicList();
    } elseif ($path == '/api/public/settings' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $settingsController->publicSettings();
    } elseif ($path == '/api/public/winners' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // Rate limiting: 30 req/min por IP
        if (!checkRateLimit($pdo, 'public-winners', 30, 60)) { respondRateLimitExceeded(); }
        // API pública para ganhadores (sem autenticação)
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');

        try {
            // Buscar campanhas concluídas com ganhador — SÓ públicas. Campanha privada (acessa só
            // por link) NÃO deve ter o ganhador vazando na listagem pública de ganhadores. NULL =
            // pública (retrocompat com campanhas antigas sem privacy definido).
            $stmt = $pdo->prepare("
                SELECT id, name, slug, winner, winner_video, prize
                FROM campaigns
                WHERE status = 'Concluído'
                AND winner IS NOT NULL
                AND winner != ''
                AND (privacy = 'public' OR privacy IS NULL)
                ORDER BY created_at DESC
                LIMIT 40
            ");
            $stmt->execute();
            $campaigns = $stmt->fetchAll(PDO::FETCH_ASSOC);

            // Parsear o campo winner no formato {fullName,winnerNumber,phone,date,imageHome,prizeName,?,videoUrl}
            $winners = [];
            foreach ($campaigns as $campaign) {
                $winnerField = $campaign['winner'];
                $winnerFieldParts = explode('},', $winnerField);
                $firstSet = trim($winnerFieldParts[0], '{}');
                $firstSetValues = explode(',', $firstSet);

                // Formato: {nome, numero, telefone, data, imagem, PREMIO, data_registro, video}
                // O PREMIO (pos 5) pode conter vírgula (ex: "JBL BOOMBOX 4 OU R$3.000,00"), o que
                // gera partes extras no explode. Os 5 campos ANTES e os 2 DEPOIS têm posição fixa;
                // tudo entre eles é o prêmio — junta de volta com vírgula.
                $fullName = trim($firstSetValues[0] ?? '');
                $winnerNumber = trim($firstSetValues[1] ?? '');
                $phone = trim($firstSetValues[2] ?? '');
                $date = trim($firstSetValues[3] ?? '');
                $imageHome = trim($firstSetValues[4] ?? '');
                $total = count($firstSetValues);
                // últimos 2 campos = data_registro e video; o prêmio ocupa da pos 5 até (total-2)
                $videoUrl = $total >= 2 ? trim($firstSetValues[$total - 1] ?? '') : '';
                $prizeName = $total > 7
                    ? trim(implode(',', array_slice($firstSetValues, 5, $total - 7)))
                    : trim($firstSetValues[5] ?? '');

                // VÍNCULO DINÂMICO com a aba Prêmios (fonte única): se o prêmio gravado no winner
                // for o rótulo genérico "Prêmio N" (dados legados/Giro da Sorte com prize vazio),
                // resolve o VALOR REAL na lista campaigns.prize (posição N-1). Assim editar o prêmio
                // na campanha reflete no ganhador, sem depender do snapshot gravado.
                if (preg_match('/^Prêmio\s+(\d+)$/u', $prizeName, $pm)) {
                    // A lista de prêmios é separada por QUEBRA DE LINHA (backend grava com
                    // implode("\n")). NÃO usar vírgula — o prêmio pode conter vírgula (R$3.000,00).
                    $prizeList = $campaign['prize']
                        ? array_values(array_filter(array_map('trim', explode("\n", $campaign['prize']))))
                        : [];
                    $idx = (int)$pm[1] - 1;
                    if ($idx >= 0 && isset($prizeList[$idx]) && $prizeList[$idx] !== '') {
                        $prizeName = $prizeList[$idx];
                    }
                }

                // Máscara canônica de telefone: (XX) ****-****
                $phoneMasked = maskPhone($phone);

                // Formata a data
                $dateFormatted = '';
                if (!empty($date)) {
                    $dateFormatted = date("d/m/Y", strtotime($date));
                }

                // Valida a imagem
                $hasValidImage = !empty($imageHome) && $imageHome !== 'user-image.png' && $imageHome !== 'default.jpg';

                $winners[] = [
                    'name' => $campaign['name'],
                    'slug' => $campaign['slug'],
                    'fullName' => formatFirstName($fullName),
                    'winnerNumber' => $winnerNumber,
                    'phone' => $phoneMasked,
                    'date' => $dateFormatted,
                    'image' => $hasValidImage ? $imageHome : null,
                    'prize' => !empty($prizeName) ? $prizeName : $campaign['name'],
                    'videoUrl' => !empty($videoUrl) ? $videoUrl : ($campaign['winner_video'] ?? ''),
                ];
            }

            echo json_encode([
                'success' => true,
                'data' => $winners
            ], JSON_UNESCAPED_UNICODE);
        } catch (Exception $e) {
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error' => 'Erro ao buscar ganhadores'
            ], JSON_UNESCAPED_UNICODE);
        }
        exit();
    } elseif (preg_match('#^/api/public/order/([a-zA-Z0-9-]+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->show($matches[1]);
    } elseif (preg_match('#^/api/public/order/([a-zA-Z0-9-]+)/status$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->status($matches[1]);
    // Rota /api/public/order/{hash}/expire REMOVIDA: a tela do comprador não precisa MANDAR expirar.
    // A expiração de pedido pendente é do servidor (pg_cron a cada 10s roda auto_expire_orders), e a
    // tela detecta via o polling de /api/public/order/{hash}/status que já existe. Mesmo modelo do
    // mercado (concorrente Frizzo/Sorteamos: só polling de status + varredor, sem rota de expire).
    } elseif (preg_match('#^/api/public/order/([a-zA-Z0-9-]+)/generate-pix$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->generatePix($matches[1]);
    } elseif ($path == '/api/auth/sign-in' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->signIn();
    } elseif ($path == '/api/auth/me' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->me();
    } elseif ($path == '/api/auth/account' && in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST'], true)) {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->updateAccount();
    } elseif ($path == '/api/auth/change-password' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->changePassword();

    // ========== 2FA Endpoints ==========

    } elseif ($path == '/api/auth/verify-2fa' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->verify2fa();
    } elseif ($path == '/api/settings/enable-2fa' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->enable2fa();
    } elseif ($path == '/api/settings/confirm-2fa' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->confirm2fa();
    } elseif ($path == '/api/settings/disable-2fa' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/AuthController.php';
        (new \controller\AuthController($pdo))->disable2fa();

    } elseif ($path == '/api/dashboard' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $dashboardController->handle();
    } elseif ($path == '/api/campaigns') {
        $authMiddleware->handle();
        $ordersController->getAllCampaigns();
        exit();
    } elseif ($path == '/api/settings' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $settingsController->getAll();
    } elseif ($path == '/api/settings' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $settingsController->save();
    } elseif ($path == '/api/settings/theme' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $settingsController->saveTheme();
    } elseif ($path == '/api/settings/terms' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $settingsController->getTerms();
    } elseif ($path == '/api/settings/terms' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $settingsController->saveTerms();
    } elseif ($path == '/api/settings/privacy' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $settingsController->getPrivacy();
    } elseif ($path == '/api/settings/privacy' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $settingsController->savePrivacy();
    } elseif ($path == '/api/settings/upload-logo' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $settingsController->uploadLogo();
    } elseif ($path == '/api/settings/upload-icon' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $settingsController->uploadIcon();
    } elseif ($path == '/api/upload/image' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        // API para upload genérico de imagem (editor, etc)
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['error' => 'Não autenticado']);
            exit();
        }
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo

        try {
            if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
                throw new Exception('Nenhuma imagem enviada');
            }

            require_once __DIR__ . '/helpers/supabase_storage.php';
            $result = uploadToSupabase($_FILES['image']);

            if (!$result['success']) {
                throw new Exception($result['error'] ?? 'Erro no upload');
            }

            echo json_encode([
                'success' => true,
                'fileName' => $result['fileName'],
                'url' => $result['url']
            ], JSON_UNESCAPED_UNICODE);
        } catch (Exception $e) {
            http_response_code(500);
            error_log('[API] Erro: ' . $e->getMessage());
            echo json_encode([
                'success' => false,
                'error' => 'Erro interno do servidor'
            ], JSON_UNESCAPED_UNICODE);
        }
        exit();
    } elseif ($path == '/api/ranking') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $rankingController->apiGetRanking();
        exit();
    } elseif ($path == '/api/award-titles') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignController->apiGetAwardTitles();
        exit();
    } elseif ($path == '/api/blocked-numbers') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignController->apiGetBlockedNumbers();
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/blocked-insert') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->insertBlockedNumber($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/blocked-update') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->updateBlockedNumber($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/blocked-delete') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->deleteBlockedNumber($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/blocked-position') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->updateBlockedPosition($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/blocked-bulk') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->bulkBlockNumbers($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/award-position') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->updateAwardPosition($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && in_array($path, ['/api/award-delete', '/api/award/delete'], true)) {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        // B2: além da rota legada award-delete, aceita /api/award/delete (padrão das dinâmicas) e
        // campaign_id da query OU do body — assim o front trata o award genérico.
        $campaignId = $_GET['campaign_id'] ?? (json_decode(file_get_contents('php://input'), true)['campaign_id'] ?? '');
        $campaignController->deleteAwardTitle($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && preg_match('/^\/api\/campaign\/(\d+)\/delete-winners$/', $path, $matches)) {
        $authMiddleware->handle();
        $campaignId = $matches[1];
        $campaignController->deleteAllWinners($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/campaign/define-winners') {
        $authMiddleware->handle();
        $campaignController->defineWinners();
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/campaign/giro-sorte') {
        $authMiddleware->handle();
        $campaignController->giroSorte();
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/campaign/duplicate') {
        $campaignController->apiDuplicate();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/campaign/delete-winners') {
        $authMiddleware->handle();
        $campaignController->deleteWinnersByHashId();
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && preg_match('/^\/api\/campaign\/([a-f0-9]+)\/update-draw-date$/', $path, $matches)) {
        $authMiddleware->handle();
        $hashId = $matches[1];
        $campaignController->updateDrawDate($hashId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && preg_match('/^\/api\/campaign\/([a-f0-9]+)\/verify-numbers$/', $path, $matches)) {
        $authMiddleware->handle();
        $hashId = $matches[1];
        $campaignController->verifyWinnerNumbers($hashId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/award-insert') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }
        $campaignId = $_GET['campaign_id'] ?? '';
        $campaignController->insertAwardTitle($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && in_array($path, ['/api/award-update', '/api/award/update'], true)) {
        // B2: além da rota legada award-update, aceita /api/award/update (padrão /{type}/{op} das
        // dinâmicas) e campaign_id da query OU do body — assim o front trata o award genérico.
        $authMiddleware->handle();
        $campaignId = $_GET['campaign_id'] ?? (json_decode(file_get_contents('php://input'), true)['campaign_id'] ?? '');
        $campaignController->updateAwardPrize($campaignId);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/award-toggle-paid') {
        // Fase B1: título premiado ganha o eixo de pagamento ("admin já quitou o prêmio"),
        // igual às 4 dinâmicas. Reusa PrizeTitleService::update (is_paid no whitelist).
        $authMiddleware->handle();
        // Escopo por campanha (hash) pra evitar IDOR — igual às outras rotas award.
        $campaignId = $_GET['campaign_id'] ?? (json_decode(file_get_contents('php://input'), true)['campaign_id'] ?? '');
        $campaignController->toggleAwardPaid($campaignId);
        exit();

    // ============== NOTIFICAÇÕES PUSH (admin) ==============
    // Web Push / VAPID. Todas as rotas exigem admin autenticado. O envio em si (nos eventos de
    // PIX/prêmio) usa sendPushToAdmins() do mesmo helper. Ver helpers/push_helper.php.
    } elseif (preg_match('#^/api/push/(vapid-key|subscribe|unsubscribe|status)$#', $path, $__pm)) {
        require_once __DIR__ . '/controllers/PushController.php';
        (new \controller\PushController($pdo))->handle($__pm[1]);

    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/dynamic-settings') {
        // Settings das 4 dinâmicas (box/roulette/scratch/kick) — grava em campaigns os flags
        // {tipo}_enabled/show_winners/show_prize/winners_type + a flag "all" própria do tipo. Substitui
        // o antigo /api/{type}/settings (roteador CRUD removido na migração pro modelo título). As
        // dinâmicas viraram prize_titles, mas estas CONFIGURAÇÕES continuam na campanha. Type-safe:
        // o prefixo de coluna vem de uma whitelist (não do input), evitando SQL dinâmico inseguro.
        $authMiddleware->handle();
        header('Content-Type: application/json');
        $body = json_decode(file_get_contents('php://input'), true) ?? [];
        $type = $body['type'] ?? '';
        // flag "all" por tipo (nome da coluna varia; scratch não tem).
        $allCol = ['box' => 'box_open_all', 'roulette' => 'roulette_spin_all', 'kick' => 'kick_play_all'];
        if (!in_array($type, GAMIFICATION_TYPES, true)) { // fonte única dos 4 tipos
            echo json_encode(['success' => false, 'message' => 'Tipo inválido']);
            exit();
        }
        $campaignHashId = $body['campaign_id'] ?? '';
        $campaign = $campaignHashId ? $campaignModel->getCampaignByHashId($campaignHashId) : null;
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }
        // input_key => coluna (prefixo fixo = $type da whitelist, nunca input cru).
        $fields = [
            'enabled'      => "{$type}_enabled",
            'show_winners' => "{$type}_show_winners",
            'show_prize'   => "{$type}_show_prize",
            'winners_type' => "{$type}_winners_type",
        ];
        if (isset($allCol[$type])) {
            // a flag "all" chega com a chave própria do tipo (spin_all/open_all/play_all) OU 'all'.
            $allKey = substr($allCol[$type], strlen($type) + 1); // ex.: 'spin_all'
            $fields[$allKey] = $allCol[$type];
            $fields['all']  = $allCol[$type];
        }
        // Upsell por combo: id de um registro da tabela de combos do tipo (não bool). Antes só o
        // chute tinha; migration 102 criou a coluna pra roleta/caixa/raspadinha, então o mapeamento
        // vale pros 4 tipos de gamificação (a coluna segue sempre o padrão {type}_upsell_combo_id).
        $fields['upsell_combo_id'] = "{$type}_upsell_combo_id";
        // Campos que NÃO são boolean (string/id): preservam o valor cru.
        $nonBool = ['winners_type', 'upsell_combo_id'];
        $updates = [];
        $params = ['id' => $campaign['id']];
        foreach ($fields as $key => $col) {
            if (!array_key_exists($key, $body)) continue;
            $ph = preg_replace('/[^a-z_]/', '', $key); // placeholder seguro
            $updates[$col] = "{$col} = :{$ph}";
            $params[$ph] = in_array($key, $nonBool, true) ? $body[$key] : ($body[$key] ? 1 : 0);
        }
        if (empty($updates)) { echo json_encode(['success' => true]); exit(); }
        try {
            $sql = "UPDATE campaigns SET " . implode(', ', array_values($updates)) . " WHERE id = :id";
            $pdo->prepare($sql)->execute($params);

            // Ao ATIVAR a roleta, semeia as 12 fatias padrão ('Prêmio 1..11' + 3 'PERDEU') no banco se a
            // campanha ainda não tiver nenhuma. Antes a config só mostrava esse padrão NA TELA (não salvava),
            // então a roleta pública — que lê roulette_prizes do banco — caía no genérico 'PERDEU/BOA SORTE'
            // até o admin salvar manualmente. Semeando na ativação, config e roda pública mostram as mesmas
            // 'Prêmio 1, 2, 3...' desde já; o admin só edita por cima. Mesmo default do roulette-slices-section.
            if ($type === 'roulette' && !empty($body['enabled'])) {
                $cnt = $pdo->prepare('SELECT COUNT(*) FROM roulette_prizes WHERE campaign_id = ?');
                $cnt->execute([$campaign['id']]);
                if ((int)$cnt->fetchColumn() === 0) {
                    $defaultSlices = [
                        ['Prêmio 1', 'fill'], ['Prêmio 2', 'fill'], ['Prêmio 3', 'fill'], ['PERDEU', 'lose'],
                        ['Prêmio 5', 'fill'], ['Prêmio 6', 'fill'], ['Prêmio 7', 'fill'], ['PERDEU', 'lose'],
                        ['Prêmio 9', 'fill'], ['Prêmio 10', 'fill'], ['Prêmio 11', 'fill'], ['PERDEU', 'lose'],
                    ];
                    $ins = $pdo->prepare('INSERT INTO roulette_prizes (campaign_id, title, type, position) VALUES (?, ?, ?, ?)');
                    foreach ($defaultSlices as $pos => [$title, $sliceType]) {
                        $ins->execute([$campaign['id'], $title, $sliceType, $pos]);
                    }
                }
            }

            echo json_encode(['success' => true]);
        } catch (PDOException $e) {
            error_log('[dynamic-settings] ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro ao salvar configurações']);
        }
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/activate-draw-titles') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $campaignHashId = $input['campaign_id'] ?? '';

        if (empty($campaignHashId)) {
            echo json_encode(['success' => false, 'message' => 'ID da campanha não informado']);
            exit();
        }

        // Busca campanha pelo hash_id
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        // Liga/desliga o toggle enable_awarded_titles. Aceita 'enabled' (true/false); se não vier,
        // ativa (retrocompat com quem chamava só pra ativar).
        $enabled = array_key_exists('enabled', $input) ? (!empty($input['enabled'])) : true;
        $stmt = $pdo->prepare("UPDATE campaigns SET enable_awarded_titles = :en WHERE id = :id");
        $stmt->bindValue(':en', $enabled, PDO::PARAM_BOOL);
        $stmt->bindValue(':id', $campaign['id'], PDO::PARAM_INT);
        $stmt->execute();

        echo json_encode([
            'success' => true,
            'enabled' => $enabled,
            'message' => $enabled ? 'Títulos premiados ativados com sucesso' : 'Títulos premiados desativados',
        ]);
        exit();
    } elseif ($path == '/api/version' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        header('Content-Type: application/json');
        // CORS já tratado no início do arquivo
        $version = trim(file_get_contents(__DIR__ . '/VERSION'));
        echo json_encode(['app' => 'sortemaxima', 'version' => $version]);
        exit();
    // Roteador CRUD genérico das dinâmicas (/api/{type}/create|update|delete|...) REMOVIDO: os 4 tipos
    // (caixa/roleta/raspadinha/chute) migraram para prize_titles e usam as rotas award-* (CRUD via
    // /api/award/*, listagem via /api/award-titles?type=X). O helper helpers/dynamic_query_helper.php
    // que servia este roteador foi removido junto. Os blocos de combos/fatias visuais abaixo FICAM
    // (são independentes: /api/{type}/combos/*, /api/roulette/prizes*).
    } elseif (preg_match('#^/api/(box|roulette|scratch|kick)/combos(?:/(create|delete|toggle|toggle-highlight|update|toggle-accumulative))?$#', $path, $__cm)
              && (($__cm[2] ?? '') === '' ? $_SERVER['REQUEST_METHOD'] === 'GET' : $_SERVER['REQUEST_METHOD'] === 'POST')) {
        $campaignController->handleCombos($__cm[1], $__cm[2] ?? '');
    // ============== API RASPADINHAS DO PEDIDO ==============
    } elseif ($path == '/api/order-scratches' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->scratches();

    } elseif (preg_match('#^/api/order-(boxes|roulettes|scratches|kicks)/(open|spin|scratch|play)$#', $path, $__rv) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->reveal($__rv[1]);

    // A rota /api/order-scratches/recreate foi REMOVIDA: a raspadinha virou prize_titles
    // (type='scratch') e usa o motor do título — não há mais "reserva de pool" a recriar (igual
    // caixa/roleta). O front-prod não chamava esta rota. Ver migration 081 + createShellsForCombo('scratch').

    // ============== API ROLETAS (Dashboard) ==============
    // A rota /api/roulette/get foi REMOVIDA: lia da tabela antiga `roulettes` (modelo morto após a
    // migração para prize_titles) e não era chamada por nenhum front. As fatias/combos visuais abaixo
    // (roulette_prizes/roulette_combos) PERMANECEM — são a casca visual da roda, não o prêmio.
    // ============== API PRÊMIOS DA ROLETA ==============
    } elseif ($path == '/api/roulette/prizes' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }

        $campaignHashId = $_GET['campaign_id'] ?? '';
        if (empty($campaignHashId)) {
            echo json_encode(['success' => false, 'message' => 'Campaign ID não informado']);
            exit();
        }

        // Busca campaign_id real
        $campaignModel = new \models\Campaign($pdo);
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        try {
            $stmt = $pdo->prepare('SELECT * FROM roulette_prizes WHERE campaign_id = ? ORDER BY position ASC');
            $stmt->execute([$campaign['id']]);
            $prizes = $stmt->fetchAll(PDO::FETCH_ASSOC);

            echo json_encode(['success' => true, 'prizes' => $prizes]);
        } catch (PDOException $e) {
            echo json_encode(['success' => false, 'message' => 'Erro ao buscar prêmios', 'prizes' => []]);
        }
        exit();

    } elseif ($path == '/api/roulette/prizes/save' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        header('Content-Type: application/json');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Unauthorized']);
            exit();
        }

        $input = json_decode(file_get_contents('php://input'), true);
        $campaignHashId = $input['campaign_id'] ?? '';
        $prizes = $input['prizes'] ?? [];

        if (empty($campaignHashId) || empty($prizes)) {
            echo json_encode(['success' => false, 'message' => 'Dados incompletos']);
            exit();
        }

        // Busca campaign_id real
        $campaignModel = new \models\Campaign($pdo);
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        try {
            // Remove prêmios antigos
            $stmt = $pdo->prepare('DELETE FROM roulette_prizes WHERE campaign_id = ?');
            $stmt->execute([$campaign['id']]);

            // Insere novos prêmios
            $stmt = $pdo->prepare('INSERT INTO roulette_prizes (campaign_id, title, type, position) VALUES (?, ?, ?, ?)');
            foreach ($prizes as $index => $prize) {
                $stmt->execute([
                    $campaign['id'],
                    $prize['title'],
                    $prize['type'],
                    $index
                ]);
            }

            echo json_encode(['success' => true, 'message' => 'Prêmios salvos com sucesso']);
        } catch (PDOException $e) {
            error_log('[API] Erro ao salvar prêmios: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();

    // ============== API PÚBLICA PRÊMIOS DA ROLETA ==============
    } elseif ($path == '/api/roulette-prizes' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // API pública para buscar prêmios da roleta de uma campanha
        header('Content-Type: application/json');

        $campaignHashId = $_GET['campaign'] ?? '';
        if (empty($campaignHashId)) {
            echo json_encode(['success' => false, 'message' => 'Campaign não informada', 'prizes' => []]);
            exit();
        }

        // Busca campaign_id real
        $campaignModel = new \models\Campaign($pdo);
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada', 'prizes' => []]);
            exit();
        }

        try {
            $stmt = $pdo->prepare('SELECT id, title, type, position FROM roulette_prizes WHERE campaign_id = ? ORDER BY position ASC');
            $stmt->execute([$campaign['id']]);
            $prizes = $stmt->fetchAll(PDO::FETCH_ASSOC);

            // Retorna também configuração spin_all
            $settings = [
                'spin_all' => isTrueish($campaign['roulette_spin_all'] ?? false) // PG boolean cru → isTrueish
            ];

            echo json_encode(['success' => true, 'prizes' => $prizes, 'settings' => $settings]);
        } catch (PDOException $e) {
            echo json_encode(['success' => false, 'message' => 'Erro ao buscar prêmios', 'prizes' => []]);
        }
        exit();

    // ============== API ROLETAS DO PEDIDO ==============
    } elseif ($path == '/api/order-roulettes' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->roulettes();

    // A rota /api/order-roulettes/recreate foi REMOVIDA: a roleta virou prize_titles (type='roulette')
    // e usa o motor do título — não há mais "reserva de pool" a recriar (igual a caixa, que não tem
    // recreate). O front-prod não chamava esta rota. Ver migration 079 + createShellsForCombo('roulette').

    // ============== API CAIXAS (DASHBOARD) ==============

    // ============== API CAIXAS DO PEDIDO (PÚBLICA) ==============
    } elseif ($path == '/api/order-boxes' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->boxes();

    // ROTA LEGADO REMOVIDA: /api/order-scratch-cards → /api/order-scratches (migrado 31/03/2026)

    // ROTA LEGADO REMOVIDA: /dashboard/campaign/updatePercentage → /api/campaigns/update-percentage
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/generate-token') {
        $ordersController->generateToken($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/expire') {
        $ordersController->expire($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/confirm-payment') {
        $ordersController->confirmPayment($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/reset-boxes') {
        $ordersController->resetBoxes($authMiddleware);

    // ============== API CHUTES (DASHBOARD) ==============
    // ============== API CHUTES DO PEDIDO (PÚBLICA) ==============
    } elseif ($path == '/api/order-kicks' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/PublicOrderController.php';
        (new \controller\PublicOrderController($pdo))->kicks();

    // A rota /api/order-kicks/recreate foi REMOVIDA: o chute virou prize_titles (type='kick') e usa
    // o motor do título — não há mais "reserva de pool" a recriar (igual caixa/roleta/raspadinha). O
    // front-prod não chamava esta rota. Ver migration 083 + createShellsForCombo('kick').

    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/reset-kicks') {
        $ordersController->resetKicks($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/reset-spins') {
        $ordersController->resetSpins($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/reset-scratches') {
        $ordersController->resetScratches($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/users/search-by-phone') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $phone = $input['phone'] ?? '';

        if (empty($phone)) {
            echo json_encode(['success' => false, 'message' => 'Telefone não informado']);
            exit();
        }

        // Busca usuário pelo telefone
        $user = $purchaseController->checkPhone($phone);

        if ($user) {
            echo json_encode([
                'success' => true,
                'user' => [
                    'hash_id' => $user['hash_id'] ?? '',
                    'name' => $user['name'],
                    'phone' => $user['phone'] ?? ''
                ]
            ]);
        } else {
            echo json_encode(['success' => false, 'message' => 'Usuário não encontrado']);
        }
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/change-participant') {
        $ordersController->changeParticipant($authMiddleware);
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/orders/add-titles') {
        $ordersController->addTitles($authMiddleware);
    } elseif ($path == '/api/orders/top-buyer' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $ordersController->topBuyer();

    } elseif ($path == '/api/orders/bigger-smaller' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $ordersController->biggerSmaller();

    // ==================== PUBLIC SCHEDULES (Horários Públicos) ====================
    } elseif (preg_match('/^\/api\/campaigns\/(\d+)\/public-schedules$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->publicList($matches[1]);
    } elseif (preg_match('/^\/api\/campaigns\/(\d+)\/public-schedules$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->publicCreate($matches[1]);
    } elseif (preg_match('/^\/api\/public-schedules\/(\d+)\/highlight$/', $path, $matches) && ($_SERVER['REQUEST_METHOD'] == 'PATCH' || $_SERVER['REQUEST_METHOD'] == 'PUT')) {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->publicHighlight($matches[1]);
    } elseif (preg_match('/^\/api\/public-schedules\/(\d+)\/flags$/', $path, $matches) && ($_SERVER['REQUEST_METHOD'] == 'PATCH' || $_SERVER['REQUEST_METHOD'] == 'PUT')) {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->publicFlags($matches[1]);
    } elseif (preg_match('/^\/api\/public-schedules\/(\d+)$/', $path, $matches) && ($_SERVER['REQUEST_METHOD'] == 'PUT' || $_SERVER['REQUEST_METHOD'] == 'PATCH')) {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->publicUpdate($matches[1]);
    } elseif (preg_match('/^\/api\/public-schedules\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'DELETE') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->publicDelete($matches[1]);

    // ==================== BUYER SCHEDULES (Top Comprador do Horário) ====================
    } elseif (preg_match('/^\/api\/campaigns\/(\d+)\/buyer-schedules$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->buyerList($matches[1]);
    } elseif (preg_match('/^\/api\/campaigns\/(\d+)\/buyer-schedules$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->buyerSave($matches[1]);
    } elseif (preg_match('/^\/api\/buyer-schedules\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'DELETE') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->buyerDelete($matches[1]);

    // ==================== PRIZE TIME DRAW (Sorteio por Horário) ====================
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/campaign/prize-time-draw') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->prizeTimeDraw();

    // ==================== PRIZE TIME WINNERS HISTORY ====================
    } elseif (preg_match('/^\/api\/campaigns\/(\d+)\/prize-time-winners$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->prizeTimeWinnersById($matches[1]);
    } elseif (preg_match('/^\/api\/campaigns\/([a-f0-9]{24})\/prize-time-winners$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/ScheduleController.php';
        (new \controller\ScheduleController($pdo, $authMiddleware))->prizeTimeWinnersByHash($matches[1]);

    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/login-ajax') {
        // Rate limiting: 30 tentativas de login por minuto por IP
        if (!checkRateLimit($pdo, 'login-ajax', 30, 60)) {
            securityLog('rate_limit_exceeded', ['endpoint' => '/api/login-ajax']);
            respondRateLimitExceeded();
        }

        // API para login via AJAX (modal navbar)
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $phone = $input['phone'] ?? '';
        $phoneCountry = !empty($input['phone_country']) ? strtoupper($input['phone_country']) : 'BR';
        $cpf = $input['cpf'] ?? '';

        // Log de tentativa de login (sem dados pessoais - LGPD)
        error_log("login-ajax: tentativa de login via " . (!empty($phone) ? 'phone' : 'cpf'));

        // Validar entrada usando funções de validação robustas
        if (!empty($cpf)) {
            $cpfValidation = validateCpf($cpf);
            if (!$cpfValidation['valid']) {
                echo json_encode(['success' => false, 'message' => $cpfValidation['message']]);
                exit();
            }
        } elseif (!empty($phone)) {
            $phoneValidation = validatePhone($phone, $phoneCountry);
            if (!$phoneValidation['valid']) {
                echo json_encode(['success' => false, 'message' => $phoneValidation['message']]);
                exit();
            }
            $phone = $phoneValidation['cleaned']; // Usar telefone limpo
        } else {
            echo json_encode(['success' => false, 'message' => 'Informe telefone ou CPF']);
            exit();
        }

        $user = null;

        if (!empty($cpf)) {
            $user = $purchaseController->checkCpf($cpf);
        } elseif (!empty($phone)) {
            $user = $purchaseController->checkPhone($phone);
        }

        if ($user) {
            $_SESSION['user'] = [
                'id' => $user['id'],
                'name' => $user['name'],
                'phone' => $user['phone'] ?? '',
                'email' => $user['email'] ?? '',
                'cpf' => $user['cpf'] ?? '',
                'image_user' => $user['image_user'] ?? '',
                'age' => $user['age'] ?? '',
                'address' => $user['address'] ?? '',
                'created_at' => isoFromUtc($user['created_at'] ?? null),
            ];

            // Log de login bem-sucedido
            securityLog('login_success', ['user_id' => $user['id']]);

            // Retornar telefone completo (não mascarado) para o frontend salvar no localStorage
            // O mascaramento é feito apenas para exibição pública
            $phoneClean = preg_replace('/\D/', '', $user['phone'] ?? '');

            echo json_encode([
                'success' => true,
                'user' => [
                    'name' => $user['name'],
                    'phone' => $phoneClean,
                    'phone_country' => $user['phone_country'] ?? 'BR',
                    'cpf' => $user['cpf'] ?? '',
                    'image' => $user['image_user'] ?? 'default-user.jpg',
                    'birth_date' => $user['age'] ?? '', // age = data de nascimento no banco
                    'email' => $user['email'] ?? '',
                    'social_name' => $user['username'] ?? '' // username = nome social no banco
                ]
            ]);
        } else {
            // Log de tentativa falha
            securityLog('login_failed', ['phone' => !empty($phone), 'cpf' => !empty($cpf)]);

            echo json_encode([
                'success' => false,
                'message' => !empty($cpf) ? 'CPF não cadastrado.' : 'Telefone não cadastrado.'
            ]);
        }
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/update-profile') {
        // API para atualizar campos do perfil do usuário
        // SEGURANÇA: Requer secret key (Server-Side API)
        header('Content-Type: application/json');

        // Validar secret key
        $apiSecret = $_SERVER['HTTP_X_API_SECRET'] ?? '';
        $expectedSecret = apiSecret();

        if ($apiSecret !== $expectedSecret) {
            http_response_code(403);
            echo json_encode([
                'success' => false,
                'message' => 'Acesso negado'
            ]);
            exit();
        }

        // Pegar phone/cpf dos headers (enviados pelo servidor Next.js)
        $userPhone = preg_replace('/[^0-9]/', '', $_SERVER['HTTP_X_USER_PHONE'] ?? '');
        $userCpf = preg_replace('/[^0-9]/', '', $_SERVER['HTTP_X_USER_CPF'] ?? '');

        if (empty($userPhone) && empty($userCpf)) {
            http_response_code(401);
            echo json_encode([
                'success' => false,
                'message' => 'Não autenticado'
            ]);
            exit();
        }

        // Buscar usuário pelo phone ou CPF
        $userModel = new \models\User($pdo);
        $user = null;
        if (!empty($userPhone)) {
            $user = $userModel->getUserByPhone($userPhone);
        }
        if (!$user && !empty($userCpf)) {
            $user = $userModel->getUserByCpf($userCpf);
        }

        if (!$user) {
            echo json_encode(['success' => false, 'message' => 'Usuário não encontrado']);
            exit();
        }

        // Dados para atualização vêm do body
        $input = json_decode(file_get_contents('php://input'), true);
        $newCpf = preg_replace('/\D/', '', $input['newCpf'] ?? '');
        $email = $input['email'] ?? '';
        $socialName = $input['socialName'] ?? '';
        $birthDate = $input['birthDate'] ?? '';

        // Validar idade mínima se birth_date foi informado — fonte única (validateBirthDate).
        if (!empty($birthDate)) {
            $minAge = intval($settings['min_age'] ?? 18);
            $birthCheck = validateBirthDate($birthDate, $minAge);
            if (!$birthCheck['valid']) {
                echo json_encode(['success' => false, 'message' => $birthCheck['message']]);
                exit();
            }
        }

        // Preparar dados para atualização
        $updateData = [];
        $userModel = new \models\User($pdo);

        // Validar CPF (se informado): dígito verificador e depois unicidade.
        //
        // ⚠️ Este endpoint é o caminho do "complete seu cadastro" do checkout e só conferia se o
        // CPF já existia em OUTRA conta — nunca se era um CPF de verdade. Um 111.111.111-11 era
        // GRAVADO no perfil; em seguida o /api/checkout (que valida) recusava o pedido, e como o
        // campo já não constava como "faltando", o comprador ficava preso sem ter como corrigir
        // pela tela. Mesma validateCpf() do /api/register e do /api/checkout.
        if (!empty($newCpf)) {
            $cpfValidation = validateCpf($newCpf);
            if (!$cpfValidation['valid']) {
                echo json_encode(['success' => false, 'message' => $cpfValidation['message']]);
                exit();
            }
            $existingCpfUser = $userModel->getUserByCpf($newCpf);
            if ($existingCpfUser && $existingCpfUser['id'] != $user['id']) {
                echo json_encode(['success' => false, 'message' => 'Este CPF já está cadastrado em outra conta']);
                exit();
            }
            $updateData['cpf'] = $newCpf;
        }

        // Validar email único (se informado)
        if (!empty($email)) {
            $existingEmailUser = $userModel->getUserByEmail($email);
            if ($existingEmailUser && $existingEmailUser['id'] != $user['id']) {
                echo json_encode(['success' => false, 'message' => 'Este e-mail já está cadastrado em outra conta']);
                exit();
            }
            $updateData['email'] = $email;
        }

        if (!empty($socialName)) $updateData['username'] = $socialName; // username = nome social
        if (!empty($birthDate)) $updateData['age'] = $birthDate; // age = data de nascimento

        if (empty($updateData)) {
            echo json_encode(['success' => false, 'message' => 'Nenhum campo para atualizar']);
            exit();
        }

        // Atualizar campos
        $userModel->updateUserFields($user['id'], $updateData);

        echo json_encode(['success' => true, 'message' => 'Perfil atualizado']);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/update-birth-date') {
        // API legada para atualizar data de nascimento
        // SEGURANÇA: Requer secret key (Server-Side API)
        header('Content-Type: application/json');

        // Validar secret key
        $apiSecret = $_SERVER['HTTP_X_API_SECRET'] ?? '';
        $expectedSecret = apiSecret();

        if ($apiSecret !== $expectedSecret) {
            http_response_code(403);
            echo json_encode([
                'success' => false,
                'message' => 'Acesso negado'
            ]);
            exit();
        }

        // Pegar phone/cpf dos headers (enviados pelo servidor Next.js)
        $userPhone = preg_replace('/[^0-9]/', '', $_SERVER['HTTP_X_USER_PHONE'] ?? '');
        $userCpf = preg_replace('/[^0-9]/', '', $_SERVER['HTTP_X_USER_CPF'] ?? '');

        if (empty($userPhone) && empty($userCpf)) {
            http_response_code(401);
            echo json_encode([
                'success' => false,
                'message' => 'Não autenticado'
            ]);
            exit();
        }

        // Buscar usuário pelo phone ou CPF
        $userModel = new \models\User($pdo);
        $user = null;
        if (!empty($userPhone)) {
            $user = $userModel->getUserByPhone($userPhone);
        }
        if (!$user && !empty($userCpf)) {
            $user = $userModel->getUserByCpf($userCpf);
        }

        if (!$user) {
            echo json_encode(['success' => false, 'message' => 'Usuário não encontrado']);
            exit();
        }

        // Data de nascimento vem do body
        $input = json_decode(file_get_contents('php://input'), true);
        $birthDate = $input['birth_date'] ?? $input['birthDate'] ?? '';

        if (empty($birthDate)) {
            echo json_encode(['success' => false, 'message' => 'Data de nascimento é obrigatória']);
            exit();
        }

        // Validar idade mínima — fonte única (validateBirthDate).
        $minAge = intval($settings['min_age'] ?? 18);
        $birthCheck = validateBirthDate($birthDate, $minAge);
        if (!$birthCheck['valid']) {
            echo json_encode(['success' => false, 'message' => $birthCheck['message']]);
            exit();
        }

        // A data de nascimento mora na coluna `age`, NÃO em `birth_date` — esta é legada e
        // está vazia em toda a base. Como `birth_date` não está na allowlist do
        // updateUserFields (models/User.php), a gravação anterior caía no `if (empty($sets))
        // return false` e NADA era escrito — mas a resposta abaixo dizia "atualizada".
        // Endpoint mentia: usuário via confirmação, banco seguia sem o dado.
        $atualizou = $userModel->updateUserFields($user['id'], ['age' => $birthDate]);

        if (!$atualizou) {
            echo json_encode(['success' => false, 'message' => 'Não foi possível atualizar a data de nascimento']);
            exit();
        }

        echo json_encode(['success' => true, 'message' => 'Data de nascimento atualizada']);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'GET' && $path == '/api/meus-titulos') {
        // API para buscar títulos do usuário (Meus Títulos modal/página)
        // SEGURANÇA: Requer secret key do servidor Next.js (Server-Side API)
        header('Content-Type: application/json');

        // Secret key para validar que a request vem do servidor Next.js
        $apiSecret = $_SERVER['HTTP_X_API_SECRET'] ?? '';
        $expectedSecret = apiSecret();

        if ($apiSecret !== $expectedSecret) {
            http_response_code(403);
            echo json_encode([
                'success' => false,
                'message' => 'Acesso negado'
            ]);
            exit();
        }

        // Pegar phone/cpf dos headers (enviados pelo servidor Next.js)
        $userPhone = $_SERVER['HTTP_X_USER_PHONE'] ?? '';
        $userCpf = $_SERVER['HTTP_X_USER_CPF'] ?? '';

        // Limpar telefone e CPF
        $userPhone = preg_replace('/[^0-9]/', '', $userPhone);
        $userCpf = preg_replace('/[^0-9]/', '', $userCpf);

        if (empty($userPhone) && empty($userCpf)) {
            http_response_code(400);
            echo json_encode([
                'success' => false,
                'message' => 'Telefone ou CPF não informado'
            ]);
            exit();
        }

        // Se tem CPF mas não tem phone, buscar telefone do usuário
        if (empty($userPhone) && !empty($userCpf)) {
            $userModel = new \models\User($pdo);
            $userByCpf = $userModel->getUserByCpf($userCpf);
            if ($userByCpf) {
                $userPhone = preg_replace('/[^0-9]/', '', $userByCpf['phone'] ?? '');
            }
        }

        if (empty($userPhone)) {
            echo json_encode(['success' => true, 'titulos' => [], 'total' => 0]);
            exit();
        }

        // Aceitar campaign_id (numérico) ou campaign_hash (hash_id)
        $campaignId = null;
        if (!empty($_GET['campaign_hash'])) {
            $stmtHash = $pdo->prepare("SELECT id FROM campaigns WHERE hash_id = ? LIMIT 1");
            $stmtHash->execute([$_GET['campaign_hash']]);
            $campRow = $stmtHash->fetch(PDO::FETCH_ASSOC);
            if ($campRow) $campaignId = (int)$campRow['id'];
        } elseif (!empty($_GET['campaign_id'])) {
            $campaignId = (int)$_GET['campaign_id'];
        }

        // Limite de resultados (padrão 3 para modal, pode ser maior para página)
        $limit = isset($_GET['limit']) ? min((int)$_GET['limit'], 200) : 3;

        // Se campaign_id foi fornecido, filtrar por campanha
        if ($campaignId) {
            $totalTitulos = $orderModel->getTotalOrdersByPhoneAndCampaign($userPhone, $campaignId);
            $orders = $orderModel->getOrdersByPhoneAndCampaign($userPhone, $campaignId, $limit, 0);
        } else {
            $totalTitulos = $orderModel->getTotalOrdersByPhone($userPhone);
            $orders = $orderModel->getOrdersByPhone($userPhone, $limit, 0);
        }

        require_once __DIR__ . '/helpers/supabase_storage.php';
        $titulos = [];
        foreach ($orders as $order) {
            $titulos[] = [
                'id' => $order['id'],
                'order_id' => $order['hash_order'] ?? $order['hash_order'],
                'name' => $order['campaign_name'],
                'image' => getImageUrl($order['campaign_image_raffle']),
                // created_at é UTC no banco → converte pra Brasília (antes mostrava UTC cru, 3h adiantado)
                'date' => formatUtcToBr($order['created_at']),
                // quantidade efetiva via fonte única. O multiplier vem no próprio $order (JOIN),
                // então passamos $order como os dois args (effectiveQuantity lê o multiplier do 2º).
                'quantity' => effectiveQuantity($order, $order),
                'total' => $order['total'],
                'is_double_chance' => isDoubleChance($order['is_double_chance'] ?? false),
                'status' => $order['payment_status'] === 'pago' ? 'active' : ($order['payment_status'] === 'pendente' ? 'pending' : 'expired'),
                // campanha: slug (pro link "Comprar novamente") + se está ativa (status 'Ativo')
                'campaign_slug' => $order['campaign_slug'] ?? null,
                'campaign_active' => ($order['campaign_status'] ?? '') === 'Ativo'
            ];
        }

        echo json_encode(['success' => true, 'titulos' => $titulos, 'total' => $totalTitulos]);
        exit();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'GET' && $path == '/api/affiliate') {
        $affiliateController->panel();
    } elseif ($_SERVER['REQUEST_METHOD'] == 'POST' && $path == '/api/affiliate/generate-code') {
        $affiliateController->generateCode();
    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // As seguintes rotas do dashboard PHP antigo foram removidas:
    // - /dashboard/delete-order → Usar OrderController API
    // - /dashboard/order/edit-{hash} → Usar Next.js
    // - /dashboard/sales-report → Usar /api/campaigns/{hash}/sales-report
    // - /dashboard/participants-report → Usar /api/campaigns/{hash}/participants-report
    // - /dashboard/users → Usar /api/user/list
    // - /dashboard/delete-user → Usar /api/user/delete/{id}
    // - /dashboard/faq/save → Usar /api/faq/save
    // - /dashboard/faq/toggle/{id} → Usar /api/faq/toggle
    // - /dashboard/faq/delete/{id} → Usar /api/faq/delete/{id}
    // ==========================================================

    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/update-settings (POST) → /api/settings
    // - /dashboard/campaign/new, /dashboard/campaign/create → /api/campaigns/save
    // - /dashboard/tracking, /dashboard/utm/* → Next.js + /api/utm/*
    // ==========================================================
    } elseif ($path == '/api/utm/save' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UtmController.php';
        (new \controller\UtmController($pdo))->save();
    } elseif (preg_match('/^\/api\/utm\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UtmController.php';
        (new \controller\UtmController($pdo))->getOne($matches[1]);
    } elseif ($path == '/api/utm/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UtmController.php';
        (new \controller\UtmController($pdo))->list();
    } elseif (preg_match('/^\/api\/utm\/delete\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UtmController.php';
        (new \controller\UtmController($pdo))->delete($matches[1]);
    } elseif (preg_match('/^\/api\/utm\/link\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UtmController.php';
        (new \controller\UtmController($pdo))->generateLink($matches[1]);

    // ==================== CUPONS DE DESCONTO (por campanha, via ?cupom=) ====================
    // CRUD espelhando o UTM, mas escopado por campanha. O front manda o hash_id da campanha
    // (productId); resolvemos pro id numérico. Ver docs/2026-08-14_FEATURE_CUPONS.md.
    } elseif ($path == '/api/coupons/save' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/CouponController.php';
        (new \controller\CouponController($pdo))->save();
    } elseif ($path == '/api/coupons/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/CouponController.php';
        (new \controller\CouponController($pdo))->list();
    } elseif (preg_match('/^\/api\/coupons\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/CouponController.php';
        (new \controller\CouponController($pdo))->getOne($matches[1]);
    } elseif (preg_match('/^\/api\/coupons\/(\d+)\/uses$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/CouponController.php';
        (new \controller\CouponController($pdo))->uses($matches[1]);
    } elseif (preg_match('/^\/api\/coupons\/delete\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/CouponController.php';
        (new \controller\CouponController($pdo))->delete($matches[1]);
    } elseif ($path == '/api/certificate' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, Authorization');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Não autenticado']);
            exit();
        }
        require_once __DIR__ . '/models/Settings.php';
        $settingsModel = new \models\Settings($pdo);
        $settings = $settingsModel->getSettings();

        // PostgreSQL retorna 't'/'f' para boolean, precisamos converter
        $isCertificated = $settings['is_certificated'] ?? false;
        if ($isCertificated === 't' || $isCertificated === true || $isCertificated === 1 || $isCertificated === '1') {
            $isCertificated = true;
        } else {
            $isCertificated = false;
        }

        echo json_encode([
            'success' => true,
            'data' => [
                'id' => $settings['id'] ?? null,
                'is_certificated' => $isCertificated,
                'certificate_text' => $settings['certificate_text'] ?? ''
            ]
        ]);
        exit();
    } elseif ($path == '/api/certificate' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, Authorization');
        $authUser = validateApiAuth($pdo);
        if (!$authUser) {
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Não autenticado']);
            exit();
        }
        require_once __DIR__ . '/models/Settings.php';
        $settingsModel = new \models\Settings($pdo);
        $settings = $settingsModel->getSettings();

        $input = json_decode(file_get_contents('php://input'), true);
        $data = [
            'is_certificated' => isset($input['is_certificated']) ? ($input['is_certificated'] ? 1 : 0) : 0,
            'certificate_text' => $input['certificate_text'] ?? '',
            'updated_at' => date('Y-m-d H:i:s')
        ];

        try {
            $settingsModel->updateCertificado($settings['id'], $data);
            // O certificado é o rodapé do site e vem do cache 'settings' do Next (TTL 1h) —
            // sem isto a alteração demorava até 1h pra aparecer (mesmo aviso do salvar tema/config).
            notifyRevalidate(['tag' => 'settings']);
            echo json_encode(['success' => true, 'message' => 'Certificado atualizado']);
        } catch (Exception $e) {
            error_log('[API] Erro: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/analytics' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/ReportController.php';
        (new \controller\ReportController($pdo))->getAnalytics();
    } elseif ($path == '/api/analytics' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/ReportController.php';
        (new \controller\ReportController($pdo))->saveAnalytics();
    } elseif ($path == '/api/faq/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/FaqController.php';
        (new \controller\FaqController($pdo))->list();
    } elseif ($path == '/api/faq/save' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/FaqController.php';
        (new \controller\FaqController($pdo))->save();
    } elseif (preg_match('#^/api/faq/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/FaqController.php';
        (new \controller\FaqController($pdo))->getOne($matches[1]);
    } elseif (preg_match('#^/api/faq/delete/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/FaqController.php';
        (new \controller\FaqController($pdo))->delete($matches[1]);
    } elseif ($path == '/api/faq/toggle' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/FaqController.php';
        (new \controller\FaqController($pdo))->toggle();
    } elseif ($path == '/api/user/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->list();
    } elseif (preg_match('#^/api/user/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->getOne($matches[1]);
    } elseif (preg_match('#^/api/user/hash/([a-zA-Z0-9]+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->getByHash($matches[1]);
    } elseif (preg_match('#^/api/user/update/([a-zA-Z0-9]+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->update($matches[1]);
    } elseif ($path == '/api/user/create' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->create();
    } elseif (preg_match('#^/api/user/delete/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->delete($matches[1]);
    } elseif (preg_match('#^/api/user/toggle-block/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->toggleBlock($matches[1]);
    } elseif (preg_match('#^/api/user/generate-affiliate/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->generateAffiliate($matches[1]);
    } elseif ($path == '/api/user/export' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/UserController.php';
        (new \controller\UserController($pdo))->export();
    } elseif ($path == '/api/webhook/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/WebhookCrudController.php';
        (new \controller\WebhookCrudController($pdo))->list();
    } elseif ($path == '/api/webhook/save' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/WebhookCrudController.php';
        (new \controller\WebhookCrudController($pdo))->save();
    } elseif (preg_match('/^\/api\/webhook\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        require_once __DIR__ . '/controllers/WebhookCrudController.php';
        (new \controller\WebhookCrudController($pdo))->getOne($matches[1]);
    } elseif (preg_match('/^\/api\/webhook\/delete\/(\d+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        require_once __DIR__ . '/controllers/WebhookCrudController.php';
        (new \controller\WebhookCrudController($pdo))->delete($matches[1]);
    // REMOVIDO: /api/users/list - Rota legado do dashboard PHP antigo (usava views/partials/users-list.php)
    // Frontend novo usa /api/user/list (singular)

    } elseif ($path == '/api/affiliate/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $affiliateController->listAffiliates();
    } elseif (preg_match('#^/api/affiliate/([a-zA-Z0-9]+)/sales$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $affiliateController->sales($matches[1]);
    } elseif ($path == '/api/affiliate/add-to-campaign' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $affiliateController->addToCampaign();
    } elseif (preg_match('#^/api/affiliate/delete/(\d+)$#', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $affiliateController->delete($matches[1]);
    // REMOVIDO: /api/affiliates/list - Rota legado do dashboard PHP antigo (usava views/partials/affiliates-list.php)
    // Frontend novo usa /api/affiliate/list (singular, sem S)

    } elseif ($path == '/api/orders/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // Set JSON header immediately
        header('Content-Type: application/json; charset=utf-8');
        // CORS já tratado no início do arquivo
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, Authorization');

        // Validate auth (supports both session and JWT)
        if (!validateApiAuth($pdo)) {
            http_response_code(401);
            echo json_encode(['success' => false, 'error' => 'Nao autenticado']);
            exit();
        }

        require_once __DIR__ . '/models/Order.php';
        require_once __DIR__ . '/models/Campaign.php';
        require_once __DIR__ . '/models/Settings.php';
        require_once __DIR__ . '/models/Statistics.php';
        $orderModel = new \models\Order($pdo);
        $campaignModel = new \models\Campaign($pdo);
        $settingsModel = new \models\Settings($pdo);
        $settings = $settingsModel->getSettingsById(1) ?? [];

        $limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 10;
        $page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
        $offset = ($page - 1) * $limit;

        $campaignParam = $_GET['campaign_id'] ?? '';
        $orderHash = $_GET['order_hash'] ?? '';
        $titleNumber = $_GET['title_number'] ?? '';
        $clientName = $_GET['client_name'] ?? '';
        $clientPhone = $_GET['client_phone'] ?? '';
        $paymentStatus = $_GET['payment_status'] ?? '';

        $filters = [
            'payment_id' => $_GET['payment_id'] ?? '',
            'e2e_id' => $_GET['e2e_id'] ?? '',
            'affiliate_code' => $_GET['affiliate_code'] ?? '',
            'date_start' => $_GET['date_start'] ?? '',
            'date_end' => $_GET['date_end'] ?? '',
            'has_affiliate' => !empty($_GET['affiliate']) && $_GET['affiliate'] == '1',
        ];

        // Detect if campaign_id is hash_id (24 hex chars) or numeric id
        $campaignId = '';
        $selectedCampaign = null;
        if (!empty($campaignParam)) {
            if (preg_match('/^[a-f0-9]{24}$/i', $campaignParam)) {
                $selectedCampaign = $campaignModel->getCampaignByHashId($campaignParam);
                if ($selectedCampaign) {
                    $campaignId = $selectedCampaign['id'];
                }
            } else {
                $campaignId = $campaignParam;
                $selectedCampaign = $campaignModel->getCampaignById($campaignId);
            }
        }

        $orders = $orderModel->getFilteredOrders($campaignId, $orderHash, $titleNumber, $clientName, $clientPhone, $paymentStatus, $limit, $offset, $filters);
        $totalOrders = $orderModel->getTotalFilteredOrders($campaignId, $orderHash, $titleNumber, $clientName, $clientPhone, $paymentStatus, $filters);
        $totalPages = ceil($totalOrders / $limit);

        // Get status counts
        $statusCounts = $orderModel->getOrdersCountByStatus($campaignId);
        $totalPagos = $statusCounts['pagos'] ?? 0;
        $totalReservados = $statusCounts['reservados'] ?? 0;
        $totalExpirados = $statusCounts['expirados'] ?? 0;
        $totalAfiliados = $statusCounts['afiliados'] ?? 0;
        $totalAllOrders = $statusCounts['total'] ?? 0;
        $totalRevenuePagos = floatval($statusCounts['total_revenue_pagos'] ?? 0);
        $totalRevenueReservados = floatval($statusCounts['total_revenue_reservados'] ?? 0);

        // Get participants count (unique users with paid orders)
        $statisticsModel = new \models\Statistics($pdo);
        $participantesPagos = $campaignId ? $statisticsModel->getParticipantsCount($campaignId) : 0;

        // Adiciona contagem de jogadas por order (pra mostrar botões de reset condicionalmente)
        $orderIds = array_column($orders, 'id');
        $prizeCounts = [];
        if (!empty($orderIds)) {
            $placeholders = implode(',', array_fill(0, count($orderIds), '?'));
            $stmtS = $pdo->prepare("SELECT order_id, COUNT(*) as cnt FROM order_scratches WHERE order_id IN ($placeholders) GROUP BY order_id");
            $stmtS->execute($orderIds);
            while ($r = $stmtS->fetch(PDO::FETCH_ASSOC)) $prizeCounts[$r['order_id']]['scratches'] = (int)$r['cnt'];

            $stmtR = $pdo->prepare("SELECT order_id, COUNT(*) as cnt FROM order_roulettes WHERE order_id IN ($placeholders) GROUP BY order_id");
            $stmtR->execute($orderIds);
            while ($r = $stmtR->fetch(PDO::FETCH_ASSOC)) $prizeCounts[$r['order_id']]['roulettes'] = (int)$r['cnt'];

            $stmtB = $pdo->prepare("SELECT order_id, COUNT(*) as cnt FROM order_boxes WHERE order_id IN ($placeholders) GROUP BY order_id");
            $stmtB->execute($orderIds);
            while ($r = $stmtB->fetch(PDO::FETCH_ASSOC)) $prizeCounts[$r['order_id']]['boxes'] = (int)$r['cnt'];

            $stmtK = $pdo->prepare("SELECT order_id, COUNT(*) as cnt FROM order_kicks WHERE order_id IN ($placeholders) GROUP BY order_id");
            $stmtK->execute($orderIds);
            while ($r = $stmtK->fetch(PDO::FETCH_ASSOC)) $prizeCounts[$r['order_id']]['kicks'] = (int)$r['cnt'];
        }
        foreach ($orders as &$o) {
            $oid = $o['id'];
            $o['scratch_count'] = $prizeCounts[$oid]['scratches'] ?? 0;
            $o['roulette_count'] = $prizeCounts[$oid]['roulettes'] ?? 0;
            $o['box_count'] = $prizeCounts[$oid]['boxes'] ?? 0;
            $o['kick_count'] = $prizeCounts[$oid]['kicks'] ?? 0;
            // created_at é gravado em UTC cru (NOW() do Postgres). Marca o offset UTC
            // (mesmo padrão da API pública, via isoFromUtc) pra o front converter pra
            // Brasília — senão a hora aparece +3h no dashboard de pedidos.
            $o['created_at'] = isoFromUtc($o['created_at'] ?? null);
        }
        unset($o);

        echo json_encode([
            'success' => true,
            'data' => $orders,
            'pagination' => [
                'total' => $totalOrders,
                'totalPages' => $totalPages,
                'currentPage' => $page,
                'limit' => $limit
            ],
            'counts' => [
                'all' => $totalAllOrders,
                'pagos' => $totalPagos,
                'reservados' => $totalReservados,
                'expirados' => $totalExpirados,
                'afiliados' => $totalAfiliados,
                'totalRevenuePagos' => $totalRevenuePagos,
                'totalRevenueReservados' => $totalRevenueReservados,
                'participantesPagos' => (int)$participantesPagos
            ]
        ]);
        exit();
    } elseif ($path == '/api/campaigns/list' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $campaignController->apiList();
    } elseif ($path == '/api/campaigns/check-slug' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $campaignController->apiCheckSlug();
    } elseif ($path == '/api/campaigns/details' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $campaignController->apiDetails();
    } elseif ($path == '/api/campaigns/enable-awarded-titles' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $campaignController->apiEnableAwardedTitles();
    } elseif ($path == '/api/campaigns/update-percentage' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $campaignController->apiUpdatePercentage();
    } elseif ($path == '/api/campaigns/update-config' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $campaignController->apiUpdateConfig();
    } elseif ($path == '/api/campaigns/save' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $campaignController->apiSave();
    } elseif ($path == '/api/campaigns/toggle-favorite' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $campaignController->apiToggleFavorite();
    } elseif ($path == '/api/campaigns/toggle-status' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $campaignController->apiToggleStatus();
    } elseif (preg_match('/^\/api\/check-numbers\/([a-zA-Z0-9]+)$/', $path, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET') {
        // Endpoint para verificar se os números foram gerados
        header('Content-Type: application/json');
        $pixUrl = $matches[1];

        $stmt = $pdo->prepare('SELECT numbers FROM orders WHERE hash_order = ?');
        $stmt->execute([$pixUrl]);
        $order = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$order) {
            echo json_encode(['status' => 'error', 'message' => 'Pedido não encontrado']);
            exit();
        }

        $numbers = trim($order['numbers'] ?? '');

        if ($numbers === '' || $numbers === null) {
            echo json_encode(['status' => 'generating']);
        } else {
            // Formata os números para exibição
            $numbersArray = array_map('trim', explode(',', $numbers));
            echo json_encode(['status' => 'ready', 'numbers' => $numbersArray]);
        }
        exit();
    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/webhooks → Usar Next.js /dashboard/webhooks
    // - /dashboard/webhooks/get → Usar /api/webhook/{id}
    // - /dashboard/webhooks/create → Usar /api/webhook/save
    // - /dashboard/webhooks/update → Usar /api/webhook/save
    // - /dashboard/webhooks/delete → Usar /api/webhook/delete/{id}
    // - /dashboard/contacts → Usar Next.js /dashboard/contacts
    // ==========================================================

    } elseif ($path == '/api/contacts/list') {
        $user = validateApiAuth($pdo);
        if (!$user) {
            header('Content-Type: application/json');
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Não autorizado']);
            exit();
        }
        $contactController->list();
        exit();
    } elseif ($path == '/api/contacts/store') {
        // Endpoint público de escrita → rate limit contra spam/flood (não tinha nenhum).
        if (!checkRateLimit($pdo, 'contacts-store', 5, 60)) {
            securityLog('rate_limit_contacts_store', ['path' => $path]);
            respondRateLimitExceeded();
        }
        $contactController->store();
        exit();
    } elseif ($path == '/api/contacts/toggle-read') {
        $user = validateApiAuth($pdo);
        if (!$user) {
            header('Content-Type: application/json');
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Não autorizado']);
            exit();
        }
        $contactController->toggleRead();
        exit();
    } elseif ($path == '/api/contacts/delete') {
        $user = validateApiAuth($pdo);
        if (!$user) {
            header('Content-Type: application/json');
            http_response_code(401);
            echo json_encode(['success' => false, 'message' => 'Não autorizado']);
            exit();
        }
        $contactController->delete();
        exit();
    } elseif ($path == '/api/contacts/unread-count') {
        $authMiddleware->handle();
        $contactController->getUnreadCount();
        exit();
    } elseif ($path == '/api/reports') {
        require_once __DIR__ . '/controllers/ReportController.php';
        (new \controller\ReportController($pdo, $authMiddleware))->reports();
    // ROTA REMOVIDA: /dashboard/reports - Substituída pelo frontend Next.js
    } elseif ($path == '/api/reports/generate') {
        require_once __DIR__ . '/controllers/ReportController.php';
        (new \controller\ReportController($pdo, $authMiddleware))->reportsGenerate();
    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/update-termos → Usar /api/settings/terms
    // - /dashboard/update-certificado → Usar /api/certificate
    // - /dashboard/update-theme → Usar /api/settings/theme
    // ==========================================================

    } elseif ($path == '/api/pay2m/register-webhook' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        try {
            // Recebe credenciais da campanha via POST
            $input = json_decode(file_get_contents('php://input'), true);

            $clientId = $input['client_id'] ?? '';
            $clientSecret = $input['client_secret'] ?? '';

            if (empty($clientId) || empty($clientSecret)) {
                echo json_encode(['success' => false, 'message' => 'Credenciais do Pay2M não informadas']);
                exit();
            }
            $baseUrl = 'https://portal.pay2m.com.br';

            // 1. Gerar token de acesso
            $tokenUrl = $baseUrl . '/api/auth/generate_token';
            $credentials = base64_encode($clientId . ':' . $clientSecret);

            $ch = curl_init($tokenUrl);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_HTTPHEADER, [
                'Authorization: Basic ' . $credentials,
                'Content-Type: application/json'
            ]);
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['grant_type' => 'client_credentials']));

            $tokenResponse = curl_exec($ch);
            curl_close($ch);

            $tokenData = json_decode($tokenResponse, true);

            if (!isset($tokenData['access_token'])) {
                echo json_encode(['success' => false, 'message' => 'Erro ao autenticar com Pay2M: ' . ($tokenData['error'] ?? 'Token não retornado')]);
                exit();
            }

            $accessToken = $tokenData['access_token'];

            // 2. Registrar webhook
            $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
            $domain = preg_replace('/^(www\.|painel\.|api\.)/', '', $host);
            $webhookUrl = 'https://api.' . $domain . '/webhook/pay2m';

            $webhookApiUrl = $baseUrl . '/api/v1/webhooks';
            $webhookData = [
                'url' => $webhookUrl,
                'authorization' => 'pay2m_webhook_' . md5($domain)
            ];

            $ch2 = curl_init($webhookApiUrl);
            curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch2, CURLOPT_HTTPHEADER, [
                'Authorization: Bearer ' . $accessToken,
                'Content-Type: application/json'
            ]);
            curl_setopt($ch2, CURLOPT_POST, true);
            curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($webhookData));

            $webhookResponse = curl_exec($ch2);
            $webhookHttpCode = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
            curl_close($ch2);

            $webhookResult = json_decode($webhookResponse, true);

            if ($webhookHttpCode == 200 || $webhookHttpCode == 201) {
                error_log('[PAY2M] Webhook registrado com sucesso: ' . $webhookUrl);
                echo json_encode(['success' => true, 'message' => 'Webhook registrado com sucesso', 'url' => $webhookUrl]);
            } else {
                error_log('[PAY2M] Erro ao registrar webhook: ' . $webhookResponse);
                echo json_encode(['success' => false, 'message' => 'Erro ao registrar webhook: ' . ($webhookResult['message'] ?? $webhookResponse)]);
            }
        } catch (Exception $e) {
            error_log('[PAY2M] Exception: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/efi/register-webhook' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        try {
            $input = json_decode(file_get_contents('php://input'), true);
            $clientId = $input['client_id'] ?? '';
            $clientSecret = $input['client_secret'] ?? '';
            $pixKey = $input['pix_key'] ?? '';
            $pixCert = $input['pix_cert'] ?? '';

            if (empty($clientId) || empty($clientSecret) || empty($pixKey)) {
                echo json_encode(['success' => false, 'message' => 'Credenciais da EFI não informadas']);
                exit();
            }
            if (empty($pixCert)) {
                echo json_encode(['success' => false, 'message' => 'Certificado da EFI não enviado. Salve a campanha com o certificado antes.']);
                exit();
            }

            // Certificado fica em storage/certs (onde o CampaignController grava o .pem convertido).
            $certPath = __DIR__ . '/storage/certs/' . $pixCert;
            if (!file_exists($certPath)) {
                echo json_encode(['success' => false, 'message' => 'Certificado não encontrado no servidor: ' . $pixCert]);
                exit();
            }

            // URL pública do webhook (api.DOMINIO/efi), derivada do host da requisição.
            $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
            $domain = preg_replace('/^(www\.|painel\.|api\.)/', '', $host);
            // /webhook/efi (não /efi): já é reconhecida como rota pública de webhook (isWebhookPath
            // = paths que começam com /webhook), sem precisar adicionar exceção no roteador.
            $webhookUrl = 'https://api.' . $domain . '/webhook/efi';

            require_once __DIR__ . '/gateways/functions/gerencianet.php';
            $result = registerEfiWebhook($clientId, $clientSecret, $certPath, $pixKey, $webhookUrl);
            $result['url'] = $webhookUrl;
            echo json_encode($result);
        } catch (Exception $e) {
            error_log('[EFI] Exception ao registrar webhook: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/check-slug' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $authMiddleware->handle();
        $slug = $_GET['slug'] ?? '';
        $hashId = $_GET['hash_id'] ?? null;
        header('Content-Type: application/json');
        if (empty($slug)) {
            echo json_encode(['available' => false, 'message' => 'Slug vazio']);
            exit();
        }
        $campaign = $campaignController->checkSlugExists($slug, $hashId);
        echo json_encode(['available' => !$campaign, 'message' => $campaign ? 'URL já está em uso' : 'URL disponível']);
        exit();
    } elseif ($path == '/api/bigger-smaller-search' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $campaignHashId = $input['campaign_id'] ?? null;
        $startDate = $input['start_date'] ?? null;
        $endDate = $input['end_date'] ?? null;
        $quantity = (int)($input['quantity'] ?? 1);
        $type = $input['type'] ?? 'menor'; // 'menor' ou 'maior'

        if (!$campaignHashId || !$startDate || !$endDate) {
            echo json_encode(['success' => false, 'message' => 'Parâmetros inválidos']);
            exit();
        }

        // Busca a campanha pelo hash_id
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];

        try {
            // Validar intervalo de tempo (máximo 100 horas)
            $startTimestamp = strtotime($startDate);
            $endTimestamp = strtotime($endDate);
            $diffHours = ($endTimestamp - $startTimestamp) / 3600;

            if ($diffHours > 100) {
                echo json_encode(['success' => false, 'message' => 'Intervalo de tempo muito grande. Máximo permitido até 100 horas.']);
                exit();
            }

            if ($diffHours < 0) {
                echo json_encode(['success' => false, 'message' => 'Data inicial deve ser menor que a data final.']);
                exit();
            }

            // Busca pedidos pagos dentro do período
            // Ordena pelo número (menor ou maior)
            $orderDirection = ($type === 'menor') ? 'ASC' : 'DESC';

            // Query busca X menores/maiores títulos vendidos no período
            $limitQty = (int)$quantity;
            $stmt = $pdo->prepare("
                SELECT
                    cn.number,
                    o.paid_at as sold_at,
                    cn.order_id,
                    o.hash_order as order_hash,
                    o.total,
                    u.name,
                    u.phone
                FROM campaign_numbers cn
                JOIN orders o ON cn.order_id = o.id
                JOIN users u ON o.id_user = u.id
                WHERE cn.campaign_id = ?
                AND cn.status = 'vendido'
                AND o.payment_status = 'pago'
                AND o.paid_at >= " . sqlBrParamToUtc() . "
                AND o.paid_at <  " . sqlBrParamToUtc() . "
                ORDER BY cn.number $orderDirection
                LIMIT $limitQty
            ");
            $stmt->execute([$campaignId, $startDate, $endDate]);
            $results = $stmt->fetchAll(PDO::FETCH_ASSOC);

            if (!empty($results)) {
                // Garrancho removido: antes buscava a settings inteira do banco em TODA request
                // (bootstrap) só pra usar o id aqui — que é sempre 1. Usa o valor direto.
                $secretKey = 'SM_SECRET_2024_1';

                $formattedResults = [];
                foreach ($results as $result) {
                    // Telefone completo e mascarado
                    $phone = $result['phone'] ?? '';
                    $phoneClean = preg_replace('/\D/', '', $phone);
                    $phoneFormatted = $phone;
                    $phoneMasked = $phone;
                    if (strlen($phoneClean) >= 10) {
                        $phoneFormatted = '(' . substr($phoneClean, 0, 2) . ') ' . substr($phoneClean, 2, 5) . '-' . substr($phoneClean, 7);
                        $phoneMasked = '(' . substr($phoneClean, 0, 2) . ') ' . substr($phoneClean, 2, 4) . '****';
                    }

                    // Nome mascarado (função canônica — só primeiro nome). Painel autenticado:
                    // 'name' completo continua disponível ao admin; 'name_masked' é padronizado.
                    $nameMasked = formatFirstName($result['name']);

                    // Valor formatado
                    $total = str_replace(',', '.', $result['total']);
                    $totalFormatted = 'R$ ' . number_format((float)$total, 2, ',', '.');

                    // Gerar token de acesso
                    $obrigadoToken = hash('sha256', $result['order_hash'] . $result['phone'] . $secretKey);

                    $formattedResults[] = [
                        'name' => $result['name'],
                        'name_masked' => $nameMasked,
                        'phone' => $phoneFormatted,
                        'phone_masked' => $phoneMasked,
                        'phone_clean' => $phoneClean,
                        'number' => (int)$result['number'],
                        'date' => date('d/m/Y H:i:s', strtotime($result['sold_at'])),
                        'total' => $totalFormatted,
                        'order_hash' => $result['order_hash'],
                        'token' => $obrigadoToken
                    ];
                }

                echo json_encode([
                    'success' => true,
                    'results' => $formattedResults
                ]);
            } else {
                echo json_encode(['success' => false, 'message' => 'Não há compras pagas para o período informado.']);
            }
        } catch (PDOException $e) {
            error_log('Erro na busca maior/menor: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro ao buscar resultados.']);
        }
        exit();
    } elseif ($path == '/api/billing-data' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $campaignHashId = $input['campaign_id'] ?? null;

        if (!$campaignHashId) {
            echo json_encode(['success' => false, 'message' => 'ID da campanha não informado']);
            exit();
        }

        // Busca a campanha pelo hash_id
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];

        try {
            // Baixa Automática: confirmada pelo WEBHOOK do gateway (webhook_paid_at preenchido).
            // ⚠️ NÃO usar txid: um pedido pode ter txid (PIX gerado) e mesmo assim ter sido baixado
            // MANUALMENTE (webhook não chegou, admin deu baixa em Revisar Pagamentos). O indicador
            // real de "automática" é o webhook_paid_at, não a presença do txid.
            $stmtAuto = $pdo->prepare("
                SELECT
                    COUNT(*) as count,
                    COALESCE(SUM(REPLACE(total, ',', '.') * 1), 0) as total
                FROM orders
                WHERE id_campaign = ?
                AND payment_status = 'pago'
                AND webhook_paid_at IS NOT NULL
            ");
            $stmtAuto->execute([$campaignId]);
            $autoData = $stmtAuto->fetch(PDO::FETCH_ASSOC);

            // Baixa Manual: pago mas SEM confirmação por webhook (baixa dada pelo admin).
            $stmtManual = $pdo->prepare("
                SELECT
                    COUNT(*) as count,
                    COALESCE(SUM(REPLACE(total, ',', '.') * 1), 0) as total
                FROM orders
                WHERE id_campaign = ?
                AND payment_status = 'pago'
                AND webhook_paid_at IS NULL
            ");
            $stmtManual->execute([$campaignId]);
            $manualData = $stmtManual->fetch(PDO::FETCH_ASSOC);

            // Totais
            $totalAmount = (float)$autoData['total'] + (float)$manualData['total'];
            $totalCount = (int)$autoData['count'] + (int)$manualData['count'];

            echo json_encode([
                'success' => true,
                'auto_total' => 'R$ ' . number_format((float)$autoData['total'], 2, ',', '.'),
                'auto_count' => number_format((int)$autoData['count'], 0, ',', '.'),
                'manual_total' => 'R$ ' . number_format((float)$manualData['total'], 2, ',', '.'),
                'manual_count' => number_format((int)$manualData['count'], 0, ',', '.'),
                'total_amount' => 'R$ ' . number_format($totalAmount, 2, ',', '.'),
                'total_count' => number_format($totalCount, 0, ',', '.')
            ]);
        } catch (PDOException $e) {
            error_log('Erro ao buscar faturamento: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro ao buscar dados de faturamento.']);
        }
        exit();
    } elseif ($path == '/api/affiliate/search' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $affiliateController->search($authMiddleware);
    } elseif ($path == '/api/campaign/affiliates' && $_SERVER['REQUEST_METHOD'] == 'GET') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        try {
            $campaignHashId = $_GET['hash_id'] ?? null;
            if (!$campaignHashId) {
                echo json_encode(['success' => false, 'message' => 'ID da campanha não informado']);
                exit();
            }

            // Busca a campanha pelo hash_id
            $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
            if (!$campaign) {
                echo json_encode(['success' => false, 'message' => 'Campanha não encontrada: ' . $campaignHashId]);
                exit();
            }

            $campaignId = $campaign['id'];

            // Busca afiliados da campanha
            $affiliateModel = new \models\Affiliate($pdo);
            $campaignAffiliates = $affiliateModel->getAffiliatesByCampaign($campaignId);

            // Busca estatísticas para cada afiliado
            $affiliatesWithStats = [];
            foreach ($campaignAffiliates as $aff) {
                $referenceCode = $aff['reference_code'] ?? '';

                // Busca vendas pelo código de referência para esta campanha específica
                $stmt = $pdo->prepare("
                    SELECT
                        COUNT(*) as sales,
                        COALESCE(SUM(quantity), 0) as titles,
                        COALESCE(SUM(CAST(REPLACE(total, ',', '.') AS NUMERIC)), 0) as revenue
                    FROM orders
                    WHERE reference_code = ? AND id_campaign = ? AND payment_status = 'pago'
                ");
                $stmt->execute([$referenceCode, $campaignId]);
                $stats = $stmt->fetch(\PDO::FETCH_ASSOC);

                $affiliatesWithStats[] = [
                    'id' => $aff['id'],
                    'name' => $aff['name'],
                    'phone' => $aff['phone'],
                    'reference_code' => $referenceCode,
                    'titles' => (int)($stats['titles'] ?? 0),
                    'sales' => (int)($stats['sales'] ?? 0),
                    'revenue' => (float)($stats['revenue'] ?? 0)
                ];
            }

            // Quem mais vendeu em cima. A query dos afiliados não tem ORDER BY (saía na ordem
            // que o Postgres entregasse) e a receita só existe depois do loop acima, então a
            // ordenação é aqui. Empate: por nome, pra lista ficar estável.
            usort($affiliatesWithStats, function ($a, $b) {
                return ($b['revenue'] <=> $a['revenue']) ?: strcasecmp($a['name'] ?? '', $b['name'] ?? '');
            });

            echo json_encode(['success' => true, 'affiliates' => $affiliatesWithStats]);
        } catch (Exception $e) {
            error_log('[API] Erro: ' . $e->getMessage());
            echo json_encode(['success' => false, 'message' => 'Erro interno do servidor']);
        }
        exit();
    } elseif ($path == '/api/campaign/affiliate/add' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $campaignHashId = $input['campaign_id'] ?? null;
        $affiliateId = $input['affiliate_id'] ?? null;

        if (!$campaignHashId || !$affiliateId) {
            echo json_encode(['success' => false, 'message' => 'Parâmetros inválidos']);
            exit();
        }

        // Busca a campanha pelo hash_id
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];

        // Verifica se já existe na tabela junction
        $checkStmt = $pdo->prepare('SELECT 1 FROM affiliate_campaigns WHERE affiliate_id = ? AND campaign_id = ?');
        $checkStmt->execute([$affiliateId, $campaignId]);
        if ($checkStmt->fetch()) {
            echo json_encode(['success' => false, 'message' => 'Afiliado já está na campanha']);
            exit();
        }

        // Insere na tabela junction
        $stmt = $pdo->prepare('INSERT INTO affiliate_campaigns (affiliate_id, campaign_id) VALUES (?, ?)');
        $result = $stmt->execute([$affiliateId, $campaignId]);

        if ($result) {
            echo json_encode(['success' => true, 'message' => 'Afiliado adicionado com sucesso']);
        } else {
            echo json_encode(['success' => false, 'message' => 'Erro ao adicionar afiliado']);
        }
        exit();
    } elseif ($path == '/api/campaign/affiliate/remove' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        $authMiddleware->handle();
        header('Content-Type: application/json');

        $input = json_decode(file_get_contents('php://input'), true);
        $campaignHashId = $input['campaign_id'] ?? null;
        $affiliateId = $input['affiliate_id'] ?? null;

        if (!$campaignHashId || !$affiliateId) {
            echo json_encode(['success' => false, 'message' => 'Parâmetros inválidos']);
            exit();
        }

        // Busca a campanha pelo hash_id
        $campaign = $campaignModel->getCampaignByHashId($campaignHashId);
        if (!$campaign) {
            echo json_encode(['success' => false, 'message' => 'Campanha não encontrada']);
            exit();
        }

        $campaignId = $campaign['id'];

        // Remove da tabela junction
        $stmt = $pdo->prepare('DELETE FROM affiliate_campaigns WHERE affiliate_id = ? AND campaign_id = ?');
        $result = $stmt->execute([$affiliateId, $campaignId]);

        if ($result) {
            echo json_encode(['success' => true, 'message' => 'Afiliado removido com sucesso']);
        } else {
            echo json_encode(['success' => false, 'message' => 'Erro ao remover afiliado']);
        }
        exit();
    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/affiliate → Usar Next.js /dashboard/affiliates
    // - /dashboard/affiliates/delete → Usar /api/affiliate/delete/{id}
    // - /dashboard/affiliates/add → Usar /api/affiliate/add-to-campaign
    // - /dashboard/affiliate/{code}/edit → Usar Next.js
    // - /dashboard/affiliate/update → Usar API
    // - /dashboard/affiliate/sale-{code} → Usar dashboard de campanha
    // ==========================================================

    } elseif ($path == '/auth/afiliado/login' && $_SERVER['REQUEST_METHOD'] == 'POST') {
        // Rate limiting: 10 tentativas por minuto por IP
        if (!checkRateLimit($pdo, 'affiliate-login', 10, 60)) { respondRateLimitExceeded(); }
        $affiliateController->loginAffiliate();
        exit();
    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /affiliates/new → Usar Next.js
    // - /affiliates/generate-code → Usar /api/user/generate-affiliate/{id}
    // - /dashboard/affiliate/generate-code → Usar /api/user/generate-affiliate/{id}
    // - /processar-pagamento → Verificar se ainda é usado
    // - /dashboard/order/update-{id} → Usar OrderController API
    // - /dashboard/user/{hash}/edit → Usar Next.js /dashboard/users/[id]/edit
    // - /dashboard/user/new → Usar Next.js /dashboard/users/new
    // - /dashboard/user/create → Usar /api/user/create
    // - /dashboard/campaign/search → Usar API campaigns
    // - /dashboard/campaign/search-title → Usar API
    // - /dashboard/campaign/search-title-award → Usar API
    // - /dashboard/campaign/search-roulette → Usar API
    // - /dashboard/campaign/search-box-award → Usar API
    // - /dashboard/campaign/search-scratch-card-award → Usar API
    // - /dashboard/campaign/search-bigger-smaller-title → Usar API
    // - /dashboard/campaign/search-prize-time → Usar API
    // - /api/campaign/prize-time-draw → Implementado abaixo
    // - /dashboard/users/export → Usar /api/user/export
    // - /edit-user-page → Usar Next.js
    // ==========================================================

    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/campaign/toggle-status → Usar /api/campaigns/toggle-status
    // - /dashboard/campaign/toggle-favorite → Usar /api/campaigns/toggle-favorite
    // - /dashboard/clear-galery → Usar /api/campaigns/save
    // - /dashboard/clear-main-image → Usar /api/campaigns/save
    // - /dashboard/remove-image → Usar /api/campaigns/save
    // - /dashboard/clear-all-images → Usar /api/campaigns/save
    // ==========================================================

    } elseif ($path == '/status-order') {
        $input = json_decode(file_get_contents('php://input'), true);
        $hash_order = $input['hash_order'] ?? null;

        header('Content-Type: application/json');

        if (!$hash_order) {
            echo json_encode([
                'success' => false,
                'status' => null,
                'error_message' => 'hash_order não enviado.'
            ]);
            exit();
        }

        $status = $orderModel->getPaymentStatusByPixUrl($hash_order);

        echo json_encode([
            'success' => $status !== null,
            'status' => $status,
            'error_message' => $status === null ? 'Erro ao verificar status.' : ''
        ]);
        exit();
    // ========== ROTAS LEGADO REMOVIDAS (2026-03-29) ==========
    // - /dashboard/order/add-title → Usar OrderController API
    // - /dashboard/order/delete-number → Usar OrderController API
    // ==========================================================

    }
    // ===== DISPATCHER ÚNICO DE WEBHOOKS DE PAGAMENTO (B2.3) =====
    // Substitui os 7 blocos elseif (um por gateway). Cada gateway tem seu adapter em
    // app/Payments/Gateways/*::handleWebhook, que contém a lógica portada byte a byte do
    // handler antigo (parse do txid, validação HMAC/token/IP, filtro de evento). O dispatcher
    // resolve o gateway pela rota, chama handleWebhook e executa o WebhookOutcome (responder +
    // processar). Adicionar/remover gateway = 1 linha no mapa abaixo + o adapter.
    elseif (preg_match('#^/(?:webhook/|api/)?(pay2m|efi|mercadopago|asaas|avex|magenpay|suitpay)(?:/webhook|/pix)?$#', $path, $__wm)
            || ($isSubdomainApi && preg_match('#^/(pay2m|efi|mercadopago|asaas|avex|magenpay|suitpay)(?:/pix)?$#', $path, $__wm))) {
        $__gw = strtolower($__wm[1]);

        // Rate limiting (mesmos limites de antes: 100/min por gateway).
        if (!checkRateLimit($pdo, 'webhook-' . $__gw, 100, 60)) {
            securityLog('webhook_rate_limit', ['gateway' => $__gw]);
            http_response_code(429);
            exit();
        }

        header('Content-Type: application/json');

        require_once __DIR__ . '/app/Payments/GatewayRegistry.php';
        if (!\app\Payments\GatewayRegistry::has($__gw)) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Gateway não suportado']);
            exit();
        }

        // Contexto que os adapters precisam (equivalente ao que os handlers liam de $_SERVER/$_GET).
        $__rawBody = file_get_contents('php://input');
        $__headers = [
            'x-signature'         => $_SERVER['HTTP_X_SIGNATURE'] ?? '',
            'x-request-id'        => $_SERVER['HTTP_X_REQUEST_ID'] ?? '',
            'asaas-access-token'  => $_SERVER['HTTP_ASAAS_ACCESS_TOKEN'] ?? '',
        ];
        $__clientIp = function_exists('getClientIP') ? getClientIP() : ($_SERVER['REMOTE_ADDR'] ?? '');

        $__adapter = (new \app\Payments\GatewayRegistry($pdo))->get($__gw);
        $__outcome = $__adapter->handleWebhook($__rawBody, $__headers, $_GET, $__clientIp, $pdo);

        if ($__outcome->action === 'reject') {
            http_response_code($__outcome->httpCode);
            echo json_encode($__outcome->response);
            exit();
        }

        if ($__outcome->action === 'ignore') {
            if ($__outcome->httpCode !== 200) { http_response_code($__outcome->httpCode); }
            echo json_encode($__outcome->response);
            exit();
        }

        // action === 'process': processa cada pagamento (byte a byte igual aos handlers).
        $__results = [];
        foreach ($__outcome->toProcess as $__item) {
            $__txid = $__item['txid'];
            $__gwName = $__item['gateway'];
            $__extra = $__item['extra'] ?? [];
            if (!empty($__item['useService'])) {
                // Pay2M: usa o OrderPaymentService (piloto Etapa B1).
                $__results[] = (new \services\OrderPaymentService($pdo))->markPaidFromGateway($__txid, $__gwName, false, $__extra);
            } else {
                $__results[] = processConfirmedPayment($pdo, $__txid, $__gwName, false, $__extra);
            }
        }

        // Resposta: se o adapter pré-definiu um corpo (ex. EFI {processed:N}), usa; senão o
        // resultado único (comportamento idêntico aos handlers que ecoavam $result).
        if (!empty($__outcome->response)) {
            echo json_encode($__outcome->response);
        } elseif (count($__results) === 1) {
            echo json_encode($__results[0]);
        } else {
            echo json_encode(['success' => true, 'processed' => count($__results)]);
        }

        // ESCALA (24/10): RESPONDE 200 ao gateway AGORA (fecha a conexão) e roda a cauda externa
        // (webhook order.paid + push + CAPI — HTTP lento) DEPOIS, no tail. Assim o gateway nunca
        // espera o HTTP externo → não estoura timeout → não reenvia (a espiral do pico). O número
        // já foi gerado+commitado dentro do processConfirmedPayment (antes daqui), então nada do
        // dado de venda depende da cauda. Fallback seguro: se fastcgi_finish_request não existir
        // (CLI/servidor sem FPM), a cauda roda síncrona mesmo — comportamento idêntico ao antigo.
        $__hasFinish = function_exists('fastcgi_finish_request');
        if ($__hasFinish) {
            @ignore_user_abort(true);
            @fastcgi_finish_request(); // a partir daqui o gateway já recebeu o 200
        }
        foreach ($__results as $__r) {
            if (empty($__r['deferred_external'])) continue; // sem cauda adiada (ex.: já processado/ignorado)
            try {
                $__d = $__r['deferred_external'];
                (new \services\OrderPaymentService($pdo))->runExternalEffects($__d['order'], $__d['campaign'] ?? [], $__d['user'] ?? null);
            } catch (\Throwable $__te) {
                error_log('[webhook tail] efeitos externos (ignorado — pagamento/número já persistidos): ' . $__te->getMessage());
            }
        }
        exit();
    }
    else {
        // 404 — rota/página não encontrada. API responde JSON; navegador direto vê HTML simples.
        http_response_code(404);
        $accept = $_SERVER['HTTP_ACCEPT'] ?? '';
        $isApi = strpos($_SERVER['REQUEST_URI'] ?? '', '/api/') !== false
            || strpos($accept, 'application/json') !== false;
        if ($isApi) {
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['success' => false, 'error' => 'Not Found', 'code' => 404], JSON_UNESCAPED_UNICODE);
        } else {
            header('Content-Type: text/html; charset=utf-8');
            echo '<!doctype html><html lang="pt-br"><head><meta charset="utf-8">'
               . '<meta name="viewport" content="width=device-width,initial-scale=1">'
               . '<title>404 — Página não encontrada</title></head>'
               . '<body style="font-family:sans-serif;text-align:center;padding:80px 20px;color:#333">'
               . '<h1 style="font-size:72px;margin:0">404</h1>'
               . '<p style="font-size:18px;color:#777">Página não encontrada.</p>'
               . '</body></html>';
        }
        exit();
    }
}
$uri = $_SERVER['REQUEST_URI'];
route($uri);