<?php
require_once __DIR__ . '/stats_functions.php';
require_once __DIR__ . '/progression_functions.php';
require_once __DIR__ . '/battle_functions.php';
require_once __DIR__ . '/viral_functions.php';
$diamondReactionModule = __DIR__ . '/diamond_reaction_functions.php';
if (is_file($diamondReactionModule)) { require_once $diamondReactionModule; }
/**
 * AURA FARMING — Etapa 12
 * Partida, visão computacional, aura visual e modo espectador em tempo real.
 *
 * O navegador processa corpo e mãos no próprio aparelho. O servidor recebe somente
 * métricas resumidas, aplica limites, valida sequência/tempo e calcula a pontuação oficial.
 */


function afWebRtcIceServers(): array
{
    $servers = [];
    if (WEBRTC_STUN_URL !== '') {
        $servers[] = ['urls' => WEBRTC_STUN_URL];
    }
    if (WEBRTC_TURN_URL !== '') {
        $turn = ['urls' => WEBRTC_TURN_URL];
        if (WEBRTC_TURN_USERNAME !== '') $turn['username'] = WEBRTC_TURN_USERNAME;
        if (WEBRTC_TURN_CREDENTIAL !== '') $turn['credential'] = WEBRTC_TURN_CREDENTIAL;
        $servers[] = $turn;
    }
    return $servers;
}

function afNivelAuraNumero(float $aura): int
{
    return match (true) {
        $aura >= 1000 => 6,
        $aura >= 851 => 5,
        $aura >= 601 => 4,
        $aura >= 301 => 3,
        $aura >= 101 => 2,
        $aura > 0 => 1,
        default => 0,
    };
}

function afNivelAuraVisual(float $aura): array
{
    $nivel = afNivelAuraNumero($aura);
    return match ($nivel) {
        6 => ['nivel' => 6, 'nome' => 'AURA MÁXIMA', 'faixa' => '1.000', 'classe' => 'maximum'],
        5 => ['nivel' => 5, 'nome' => 'AURA EXTREMA', 'faixa' => '851–999', 'classe' => 'extreme'],
        4 => ['nivel' => 4, 'nome' => 'AURA PODEROSA', 'faixa' => '601–850', 'classe' => 'powerful'],
        3 => ['nivel' => 3, 'nome' => 'AURA CARREGADA', 'faixa' => '301–600', 'classe' => 'charged'],
        2 => ['nivel' => 2, 'nome' => 'AURA DESPERTA', 'faixa' => '101–300', 'classe' => 'awakened'],
        1 => ['nivel' => 1, 'nome' => 'AURA LATENTE', 'faixa' => '1–100', 'classe' => 'latent'],
        default => ['nivel' => 0, 'nome' => 'SEM AURA', 'faixa' => '0', 'classe' => 'empty'],
    };
}

function afApiOuSessaoUsuario(PDO $pdo, ?string $feature = null, array $context = []): array
{
    $token = bearerToken();
    if ($token !== '') {
        $u = apiUsuario($pdo);
    } else {
        if (!isLoggedIn()) {
            jsonResponse(['ok' => false, 'erro' => 'Sessão inválida ou expirada.'], 401);
        }
        $u = usuarioAtual();
        $u['sessao_id'] = (int)($_SESSION['auth_session_id'] ?? 0);
        $u['dispositivo_id'] = (int)($_SESSION['device_id'] ?? 0);
        if (function_exists('afAssertApiAgeAccess')) afAssertApiAgeAccess($pdo,$u);
        if (function_exists('afAssertApiLegalAccess')) afAssertApiLegalAccess($pdo,$u);
    }
    if ($feature !== null && function_exists('afAssertApiAgeAccess')) afAssertApiAgeAccess($pdo,$u,$feature,$context);
    return $u;
}

function afValidarCsrfApi(): void
{
    if (bearerToken() !== '') {
        return;
    }

    $token = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
    if (!validarCsrfToken($token)) {
        jsonResponse(['ok' => false, 'erro' => 'Token de segurança inválido. Atualize a página e tente novamente.'], 419);
    }
}

function afGerarTokenPublico(PDO $pdo): string
{
    do {
        $token = bin2hex(random_bytes(8));
        $st = $pdo->prepare('SELECT id FROM af_partidas WHERE public_token=? LIMIT 1');
        $st->execute([$token]);
    } while ($st->fetchColumn());
    return $token;
}

function afPontuacaoSocial(array $p): float
{
    if (array_key_exists('pontuacao_social', $p)) {
        return round((float)$p['pontuacao_social'], 2);
    }
    return round(((int)($p['likes_recebidos'] ?? 0) * LIKE_VALOR) + ((int)($p['superlikes_recebidos'] ?? 0) * SUPERLIKE_VALOR), 2);
}

function afContarEspectadores(PDO $pdo, int $partidaId): int
{
    $timeout = (int)LIVE_PRESENCE_TIMEOUT;
    $st = $pdo->prepare("SELECT COUNT(*) FROM af_partida_espectadores WHERE partida_id=? AND saiu_em IS NULL AND ultimo_ping >= DATE_SUB(NOW(), INTERVAL {$timeout} SECOND)");
    $st->execute([$partidaId]);
    return (int)$st->fetchColumn();
}

function afBuscarPartidaPublicaPorToken(PDO $pdo, string $token, bool $forUpdate = false, int $viewerId = 0): ?array
{
    $token = preg_replace('/[^a-f0-9]/', '', strtolower($token));
    if (strlen($token) !== 16) return null;
    $sql = "SELECT p.*,u.nome,u.username,u.aura_id,u.foto_perfil,u.foto_externa,u.titulo,u.nivel,u.melhor_pontuacao,
                   pr.cidade,pr.estado
            FROM af_partidas p
            JOIN af_usuarios u ON u.id=p.usuario_id
            LEFT JOIN af_perfis pr ON pr.usuario_id=u.id
            WHERE p.public_token=? AND p.controlled_event_id IS NULL AND u.status=1 LIMIT 1";
    if ($forUpdate) $sql .= ' FOR UPDATE';
    $st = $pdo->prepare($sql);
    $st->execute([$token]);
    $partida=$st->fetch() ?: null;
    if($partida && function_exists('afMinorPolicyCanExposePublicContent') && !afMinorPolicyCanExposePublicContent($pdo,(int)$partida['usuario_id'],'public_live')) return null;
    if($partida && $viewerId>0 && (int)$partida['usuario_id']!==$viewerId && function_exists('afModerationAssertCanViewLive')){try{afModerationAssertCanViewLive($pdo,$partida,$viewerId);}catch(Throwable $e){return null;}}
    return $partida;
}

function afBuscarAoVivoPorAuraId(PDO $pdo, string $auraId, int $viewerId = 0): ?array
{
    $auraId = strtoupper(trim($auraId));
    $st = $pdo->prepare("SELECT p.*,u.nome,u.username,u.aura_id,u.foto_perfil,u.foto_externa,u.titulo,u.nivel,u.melhor_pontuacao,
                                pr.cidade,pr.estado
                         FROM af_partidas p
                         JOIN af_usuarios u ON u.id=p.usuario_id
                         LEFT JOIN af_perfis pr ON pr.usuario_id=u.id
                         WHERE u.aura_id=? AND u.status=1 AND p.controlled_event_id IS NULL AND p.status='em_andamento'
                           AND p.expira_em > NOW()
                         ORDER BY p.id DESC LIMIT 1");
    $st->execute([$auraId]);
    $partida=$st->fetch() ?: null;
    if($partida && function_exists('afMinorPolicyCanExposePublicContent') && !afMinorPolicyCanExposePublicContent($pdo,(int)$partida['usuario_id'],'public_live')) return null;
    if($partida && $viewerId>0 && (int)$partida['usuario_id']!==$viewerId && function_exists('afModerationAssertCanViewLive')){try{afModerationAssertCanViewLive($pdo,$partida,$viewerId);}catch(Throwable $e){return null;}}
    return $partida;
}

function afListarPartidasAoVivo(PDO $pdo, int $limite = 12, int $viewerId = 0): array
{
    $limite = max(1, min(30, $limite));
    $timeout = (int)LIVE_PRESENCE_TIMEOUT;
    $sql = "SELECT p.*,u.nome,u.username,u.aura_id,u.foto_perfil,u.foto_externa,u.titulo,u.nivel,
                   pr.cidade,pr.estado,
                   (SELECT COUNT(*) FROM af_partida_espectadores e
                    WHERE e.partida_id=p.id AND e.saiu_em IS NULL
                      AND e.ultimo_ping >= DATE_SUB(NOW(), INTERVAL {$timeout} SECOND)) espectadores_ativos
            FROM af_partidas p
            JOIN af_usuarios u ON u.id=p.usuario_id
            LEFT JOIN af_perfis pr ON pr.usuario_id=u.id
            WHERE p.status='em_andamento' AND p.controlled_event_id IS NULL AND p.expira_em > NOW() AND u.status=1
            ORDER BY espectadores_ativos DESC,p.pontuacao_total DESC,p.iniciada_em DESC
            LIMIT {$limite}";
    $rows=$pdo->query($sql)->fetchAll() ?: [];
    if(function_exists('afMinorPolicyCanExposePublicContent')){
        $rows=array_values(array_filter($rows,static fn($row)=>afMinorPolicyCanExposePublicContent($pdo,(int)$row['usuario_id'],'public_live')));
    }
    if($viewerId>0 && function_exists('afModerationAssertCanViewLive')){
        $rows=array_values(array_filter($rows,static function($row)use($pdo,$viewerId){if((int)$row['usuario_id']===$viewerId)return true;try{afModerationAssertCanViewLive($pdo,$row,$viewerId);return true;}catch(Throwable $e){return false;}}));
    }
    return $rows;
}

function afInteracoesDoEspectador(PDO $pdo, int $partidaId, int $usuarioId): array
{
    $st = $pdo->prepare('SELECT tipo FROM af_partida_interacoes WHERE partida_id=? AND usuario_id=?');
    $st->execute([$partidaId,$usuarioId]);
    $tipos = array_column($st->fetchAll() ?: [], 'tipo');
    return ['like'=>in_array('like',$tipos,true),'superlike'=>in_array('superlike',$tipos,true)];
}

function afRegistrarPresencaEspectador(PDO $pdo, array $partida, int $usuarioId, int $dispositivoId): void
{
    if ((int)$partida['usuario_id'] === $usuarioId) return;
    if(function_exists('afModerationAssertCanViewLive'))afModerationAssertCanViewLive($pdo,$partida,$usuarioId);
    $st = $pdo->prepare("INSERT INTO af_partida_espectadores
        (partida_id,usuario_id,dispositivo_id,entrou_em,ultimo_ping,saiu_em,ip_address)
        VALUES (?,?,?,NOW(),NOW(),NULL,?)
        ON DUPLICATE KEY UPDATE dispositivo_id=VALUES(dispositivo_id),ultimo_ping=NOW(),saiu_em=NULL,ip_address=VALUES(ip_address)");
    $st->execute([(int)$partida['id'],$usuarioId,$dispositivoId ?: null,clientIp()]);
    if ($st->rowCount() === 1) {
        $dados = json_encode(['espectador_id'=>$usuarioId],JSON_UNESCAPED_UNICODE);
        $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'espectador_entrada',0,?,NOW())")
            ->execute([(int)$partida['id'],(int)$partida['usuario_id'],$dados]);
    }
}

function afSairComoEspectador(PDO $pdo, int $partidaId, int $usuarioId): void
{
    $st = $pdo->prepare('UPDATE af_partida_espectadores SET saiu_em=NOW(),ultimo_ping=NOW() WHERE partida_id=? AND usuario_id=?');
    $st->execute([$partidaId,$usuarioId]);
}

function afEstadoEspectador(PDO $pdo, array $p, int $usuarioId): array
{
    if((int)$p['usuario_id']!==$usuarioId && function_exists('afModerationAssertCanViewLive'))afModerationAssertCanViewLive($pdo,$p,$usuarioId);
    $base = afPartidaPublica($p, $pdo);
    $base['jogador'] = [
        'nome'=>(string)$p['nome'], 'username'=>(string)$p['username'], 'aura_id'=>(string)$p['aura_id'],
        'avatar'=>urlAvatar($p['foto_perfil'] ?? null,$p['foto_externa'] ?? null),
        'titulo'=>(string)($p['titulo'] ?? 'Aura Initiate'), 'nivel'=>(int)($p['nivel'] ?? 1),
        'cidade'=>(string)($p['cidade'] ?? ''), 'estado'=>(string)($p['estado'] ?? ''),
    ];
    $base['interacoes_usuario'] = afInteracoesDoEspectador($pdo,(int)$p['id'],$usuarioId);
    $base['pode_interagir'] = (int)$p['usuario_id'] !== $usuarioId && $p['status'] === 'em_andamento' && (!function_exists('afModerationCanUsersInteract') || afModerationCanUsersInteract($pdo,(int)$p['usuario_id'],$usuarioId,afModerationUserIsMinor($pdo,(int)$p['usuario_id'])||afModerationUserIsMinor($pdo,$usuarioId)));
    $base['comments_enabled'] = function_exists('afModerationCommentsEnabledForMatch') ? afModerationCommentsEnabledForMatch($pdo,$p,$usuarioId) : false;
    $base['webrtc'] = ['enabled'=>WEBRTC_ENABLED,'ice_servers'=>afWebRtcIceServers(),'max_viewers'=>WEBRTC_MAX_VIEWERS];
    if (function_exists('afDiamondReactionCosts')) {
        try {
            $base['diamond_reactions'] = afDiamondReactionCosts($pdo);
            $base['diamond_balance'] = function_exists('afDiamondsGetBalance') ? afDiamondsGetBalance($pdo, $usuarioId) : 0;
        } catch (Throwable $e) {
            $base['diamond_reactions'] = [
                'like' => ['diamonds' => 2, 'aura' => 2, 'xp' => 5],
                'superlike' => ['diamonds' => 10, 'aura' => 15, 'xp' => 25],
            ];
            $base['diamond_balance'] = 0;
        }
    }
    return $base;
}

function afEnviarInteracao(PDO $pdo, array $partida, int $usuarioId, int $dispositivoId, string $tipo): array
{
    if ((int)($partida['controlled_event_id'] ?? 0) > 0) throw new RuntimeException('Interações sociais são desativadas em eventos infantis.');
    afAssertContaPermitida($pdo,$usuarioId,false);
    afRateLimitOrFail($pdo,'live_interaction',$usuarioId.'|'.clientIp(),20,60,120);
    $tipo = function_exists('afDiamondReactionNormalizeType') ? afDiamondReactionNormalizeType($tipo) : strtolower($tipo);
    if (!in_array($tipo,['like','superlike'],true)) throw new RuntimeException('Interação inválida.');
    if ((int)$partida['usuario_id'] === $usuarioId) throw new RuntimeException('Você não pode pontuar sua própria partida.');
    if(function_exists('afModerationAssertCanViewLive'))afModerationAssertCanViewLive($pdo,$partida,$usuarioId);
    if (function_exists('afDiamondReactionAssertReady')) afDiamondReactionAssertReady($pdo);

    $rules = function_exists('afDiamondReactionRules') ? afDiamondReactionRules($pdo, $tipo) : [
        'type' => $tipo,
        'label' => $tipo === 'superlike' ? 'Super Like' : 'Like',
        'ledger_type' => $tipo === 'superlike' ? 'super_like_spend' : 'like_spend',
        'event_type' => $tipo === 'superlike' ? 'super_like' : 'like',
        'diamonds' => $tipo === 'superlike' ? 10 : 2,
        'aura' => $tipo === 'superlike' ? 15.0 : 2.0,
        'xp' => $tipo === 'superlike' ? 25 : 5,
    ];

    $pdo->beginTransaction();
    try {
        $p = afBuscarPartidaPublicaPorToken($pdo,(string)$partida['public_token'],true,$usuarioId);
        if (!$p || $p['status'] !== 'em_andamento' || empty($p['expira_em']) || strtotime((string)$p['expira_em']) <= time()) {
            throw new RuntimeException('Esta partida não está mais ao vivo.');
        }
        afRegistrarPresencaEspectador($pdo,$p,$usuarioId,$dispositivoId);

        $diamondCost = (int)$rules['diamonds'];
        $valor = (float)$rules['aura'];
        $xpAwarded = (int)$rules['xp'];

        $stDevice=$pdo->prepare('SELECT fingerprint_hash,risco,contas_vinculadas FROM af_dispositivos WHERE id=? AND usuario_id=? LIMIT 1');
        $stDevice->execute([$dispositivoId,$usuarioId]);
        $device=$stDevice->fetch()?:[];
        if ($dispositivoId>0 && $dispositivoId===(int)($p['dispositivo_id']??0)) throw new RuntimeException('Interação bloqueada: mesmo dispositivo do jogador.');
        $interactionRisk=(int)($device['risco']??0);
        if ((int)($device['contas_vinculadas']??1)>=4) $interactionRisk+=12;
        if (afIpHash()===afPrivacyHash((string)($p['ip_address']??''))) $interactionRisk+=3;
        $accepted=$interactionRisk<25;
        $rejectReason=$accepted?null:'dispositivo_de_alto_risco';

        if (!$accepted) {
            if (function_exists('afDiamondReactionAudit')) {
                afDiamondReactionAudit($pdo, 'diamond_reaction_blocked_antifraud', $usuarioId, (int)$p['usuario_id'], (int)$p['id'], [
                    'type' => $tipo,
                    'diamonds' => $diamondCost,
                    'risk' => $interactionRisk,
                    'reason' => $rejectReason,
                    'severity' => 'medium',
                ]);
            }
            afLogSeguranca($pdo,'interacao_social_rejeitada',$interactionRisk,['tipo'=>$tipo,'motivo'=>$rejectReason],$usuarioId,(int)$p['id'],$dispositivoId,(string)($device['fingerprint_hash']??''));
            throw new RuntimeException('Sua interação foi bloqueada pela proteção antifraude. Nenhum Diamante foi usado.');
        }

        if (function_exists('afDiamondsCanSpend') && !afDiamondsCanSpend($pdo, $usuarioId, $diamondCost)) {
            if (function_exists('afDiamondReactionAudit')) {
                afDiamondReactionAudit($pdo, 'diamond_reaction_blocked_insufficient_balance', $usuarioId, (int)$p['usuario_id'], (int)$p['id'], [
                    'type' => $tipo,
                    'diamonds' => $diamondCost,
                    'balance' => function_exists('afDiamondsGetBalance') ? afDiamondsGetBalance($pdo, $usuarioId) : 0,
                    'severity' => 'low',
                ]);
            }
            throw new RuntimeException(function_exists('afDiamondReactionInsufficientMessage') ? afDiamondReactionInsufficientMessage($pdo, $tipo) : 'Diamantes insuficientes.');
        }

        try {
            $st = $pdo->prepare('INSERT INTO af_partida_interacoes (partida_id,usuario_id,dispositivo_id,tipo,valor,aceita,risco,motivo_rejeicao,criada_em,ip_address,ip_hash,fingerprint_hash) VALUES (?,?,?,?,?,?,?,?,NOW(),?,?,?)');
            $st->execute([(int)$p['id'],$usuarioId,$dispositivoId ?: null,$tipo,$valor,1,$interactionRisk,null,clientIp(),afIpHash(),$device['fingerprint_hash']??null]);
        } catch (PDOException $e) {
            if ((string)$e->getCode() === '23000') throw new RuntimeException($tipo === 'like' ? 'Você já enviou seu Like nesta partida.' : 'Você já enviou seu Super Like nesta partida.');
            throw $e;
        }
        $partidaInteracaoId = (int)$pdo->lastInsertId();

        $metadata = [
            'partida_id' => (int)$p['id'],
            'to_user_id' => (int)$p['usuario_id'],
            'reaction_type' => $tipo,
            'aura_points_awarded' => $valor,
            'xp_awarded' => $xpAwarded,
            'partida_interacao_id' => $partidaInteracaoId,
            'source' => 'diamonds_part06',
        ];

        $st = $pdo->prepare('INSERT INTO af_paid_reactions
            (from_user_id,to_user_id,target_type,target_id,reaction_type,diamonds_spent,aura_points_awarded,xp_awarded,ledger_id,partida_interacao_id,status,metadata_json,created_at)
            VALUES (?,?,?,?,?,?,?,?,?,?,\'completed\',?,NOW())');
        $st->execute([
            $usuarioId,
            (int)$p['usuario_id'],
            'partida',
            (int)$p['id'],
            $tipo,
            $diamondCost,
            $valor,
            $xpAwarded,
            null,
            $partidaInteracaoId ?: null,
            json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        ]);
        $paidReactionId = (int)$pdo->lastInsertId();

        if (!function_exists('afDiamondsDebit')) {
            throw new RuntimeException('Função de carteira de Diamantes ausente. Instale a Parte 02 antes desta etapa.');
        }
        $ledger = afDiamondsDebit($pdo, $usuarioId, $diamondCost, (string)$rules['ledger_type'], $rules['label'].' enviado', [
            'reference_type' => 'paid_reaction',
            'reference_id' => (string)$paidReactionId,
            'paid_reaction_id' => $paidReactionId,
            'metadata' => $metadata,
            'idempotency_key' => 'paid_reaction:'.$tipo.':match:'.(int)$p['id'].':from:'.$usuarioId,
        ]);
        $ledgerId = (int)($ledger['ledger_id'] ?? 0);
        if ($ledgerId > 0) {
            $pdo->prepare('UPDATE af_paid_reactions SET ledger_id=? WHERE id=?')->execute([$ledgerId, $paidReactionId]);
        }

        $likeInc = $tipo === 'like' ? 1 : 0;
        $superInc = $tipo === 'superlike' ? 1 : 0;
        $novaPontuacaoSocial = round(afPontuacaoSocial($p) + $valor, 2);
        $novaPontuacaoTotal = round((float)$p['aura_gerada'] + $novaPontuacaoSocial, 2);
        $st = $pdo->prepare('UPDATE af_partidas SET likes_recebidos=likes_recebidos+?,superlikes_recebidos=superlikes_recebidos+?,pontuacao_social=?,pontuacao_total=?,atualizada_em=NOW() WHERE id=?');
        $st->execute([$likeInc,$superInc,$novaPontuacaoSocial,$novaPontuacaoTotal,(int)$p['id']]);

        $xpResult = function_exists('afDiamondReactionAwardXp')
            ? afDiamondReactionAwardXp($pdo, $usuarioId, $xpAwarded, $tipo, (int)$p['id'], (int)$p['usuario_id'])
            : ['xp' => 0];

        $dados = json_encode([
            'espectador_id'=>$usuarioId,
            'tipo'=>$tipo,
            'diamonds_spent'=>$diamondCost,
            'aura_points_awarded'=>$valor,
            'xp_awarded'=>$xpAwarded,
            'paid_reaction_id'=>$paidReactionId,
            'ledger_id'=>$ledgerId,
        ],JSON_UNESCAPED_UNICODE);
        $pdo->prepare('INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,?,?,?,NOW())')
            ->execute([(int)$p['id'],(int)$p['usuario_id'],(string)$rules['event_type'],$valor,$dados]);

        if ($tipo === 'superlike') {
            criarNotificacao($pdo,(int)$p['usuario_id'],'super_like','SUPER LIKE recebido!','Um espectador usou '.$diamondCost.' Diamantes e adicionou +'.number_format($valor,0,',','.').' pontos de Aura à sua partida.',['partida_id'=>(int)$p['id'],'paid_reaction_id'=>$paidReactionId]);
        } else {
            criarNotificacao($pdo,(int)$p['usuario_id'],'like','Like recebido!','Um espectador usou '.$diamondCost.' Diamantes e adicionou +'.number_format($valor,0,',','.').' pontos de Aura à sua partida.',['partida_id'=>(int)$p['id'],'paid_reaction_id'=>$paidReactionId]);
        }

        if (function_exists('afDiamondReactionAudit')) {
            afDiamondReactionAudit($pdo, 'diamond_reaction_completed', $usuarioId, (int)$p['usuario_id'], (int)$p['id'], [
                'paid_reaction_id' => $paidReactionId,
                'ledger_id' => $ledgerId,
                'type' => $tipo,
                'diamonds_spent' => $diamondCost,
                'aura_points_awarded' => $valor,
                'xp_awarded' => $xpAwarded,
                'wallet_balance' => $ledger['balance'] ?? null,
                'xp_result' => $xpResult,
            ]);
        }

        $pdo->commit();
        if (function_exists('recarregarSessaoUsuario')) recarregarSessaoUsuario($pdo,$usuarioId);
        $atual = afBuscarPartidaPublicaPorToken($pdo,(string)$p['public_token'],false,$usuarioId);
        return afEstadoEspectador($pdo,$atual,$usuarioId);
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}

function afEnviarSinalWebRtc(PDO $pdo, array $partida, int $usuarioId, string $tipo, array $payload, int $destinatarioId = 0): void
{
    if ((int)($partida['controlled_event_id'] ?? 0) > 0) throw new RuntimeException('Transmissão pública é proibida em eventos infantis.');
    if (!WEBRTC_ENABLED) throw new RuntimeException('Transmissão WebRTC desativada.');
    afRateLimitOrFail($pdo,'webrtc_signal',$usuarioId.'|'.(int)$partida['id'],120,60,60);
    if (!in_array($tipo,['offer','answer','ice','bye'],true)) throw new RuntimeException('Sinal WebRTC inválido.');
    if ($partida['status'] !== 'em_andamento' || empty($partida['expira_em']) || strtotime((string)$partida['expira_em']) <= time()) {
        throw new RuntimeException('A transmissão não está mais ao vivo.');
    }
    $isPlayer = (int)$partida['usuario_id'] === $usuarioId;
    if(!$isPlayer && function_exists('afModerationAssertCanViewLive'))afModerationAssertCanViewLive($pdo,$partida,$usuarioId);
    if ($isPlayer) {
        if ($destinatarioId <= 0) throw new RuntimeException('Espectador de destino inválido.');
        if(function_exists('afModerationAssertCanViewLive'))afModerationAssertCanViewLive($pdo,$partida,$destinatarioId);
        $st = $pdo->prepare('SELECT id FROM af_partida_espectadores WHERE partida_id=? AND usuario_id=? LIMIT 1');
        $st->execute([(int)$partida['id'],$destinatarioId]);
        if (!$st->fetchColumn()) throw new RuntimeException('Espectador não conectado.');
    } else {
        $destinatarioId = (int)$partida['usuario_id'];
        afRegistrarPresencaEspectador($pdo,$partida,$usuarioId,(int)($_SESSION['device_id'] ?? 0));
    }
    $json = json_encode($payload,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
    if ($json === false || strlen($json) > 120000) throw new RuntimeException('Sinal WebRTC excedeu o limite permitido.');
    $st = $pdo->prepare('INSERT INTO af_live_sinais (partida_id,remetente_id,destinatario_id,tipo,payload,criada_em) VALUES (?,?,?,?,?,NOW())');
    $st->execute([(int)$partida['id'],$usuarioId,$destinatarioId,$tipo,$json]);
    $pdo->prepare('DELETE FROM af_live_sinais WHERE criada_em < DATE_SUB(NOW(),INTERVAL 10 MINUTE)')->execute();
}

function afReceberSinaisWebRtc(PDO $pdo, array $partida, int $usuarioId): array
{
    if ((int)($partida['controlled_event_id'] ?? 0) > 0) throw new RuntimeException('Transmissão pública é proibida em eventos infantis.');
    if((int)$partida['usuario_id']!==$usuarioId && function_exists('afModerationAssertCanViewLive'))afModerationAssertCanViewLive($pdo,$partida,$usuarioId);
    $pdo->beginTransaction();
    try {
        $st = $pdo->prepare('SELECT id,remetente_id,tipo,payload,criada_em FROM af_live_sinais WHERE partida_id=? AND destinatario_id=? AND processado_em IS NULL ORDER BY id ASC LIMIT 50 FOR UPDATE');
        $st->execute([(int)$partida['id'],$usuarioId]);
        $rows = $st->fetchAll() ?: [];
        if ($rows) {
            $ids = array_map('intval',array_column($rows,'id'));
            $placeholders = implode(',',array_fill(0,count($ids),'?'));
            $pdo->prepare("UPDATE af_live_sinais SET processado_em=NOW() WHERE id IN ({$placeholders})")->execute($ids);
        }
        $pdo->commit();
        return array_map(static function(array $r): array {
            $payload = json_decode((string)$r['payload'],true);
            return ['id'=>(int)$r['id'],'remetente_id'=>(int)$r['remetente_id'],'tipo'=>(string)$r['tipo'],'payload'=>is_array($payload)?$payload:[],'criada_em'=>$r['criada_em']];
        },$rows);
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}

function afBuscarPartidaPorSessao(PDO $pdo, int $usuarioId, string $sessaoId, bool $forUpdate = false): ?array
{
    $sql = 'SELECT * FROM af_partidas WHERE usuario_id=? AND sessao_id=? LIMIT 1';
    if ($forUpdate) {
        $sql .= ' FOR UPDATE';
    }
    $st = $pdo->prepare($sql);
    $st->execute([$usuarioId, $sessaoId]);
    return $st->fetch() ?: null;
}

function afBuscarPartidaAtiva(PDO $pdo, int $usuarioId): ?array
{
    $st = $pdo->prepare("SELECT * FROM af_partidas WHERE usuario_id=? AND status IN ('aguardando','em_andamento') ORDER BY id DESC LIMIT 1");
    $st->execute([$usuarioId]);
    $partida = $st->fetch() ?: null;

    if ($partida && $partida['status'] === 'aguardando' && strtotime((string)$partida['criada_em']) < time() - 600) {
        afCancelarPartida($pdo, $usuarioId, (string)$partida['sessao_id']);
        return null;
    }
    if ($partida && $partida['status'] === 'em_andamento' && $partida['expira_em'] && strtotime((string)$partida['expira_em']) <= time()) {
        return afFinalizarPartida($pdo, $usuarioId, (string)$partida['sessao_id'], true);
    }

    return $partida;
}

function afPartidaPublica(array $p, ?PDO $pdo = null): array
{
    $agora = time();
    if ($p['status'] === 'aguardando' || !$p['iniciada_em']) {
        $inicio = $agora;
        $fim = $agora + SESSAO_DURACAO;
        $restante = SESSAO_DURACAO;
        $decorrido = 0;
    } else {
        $inicio = strtotime((string)$p['iniciada_em']);
        $fim = $p['expira_em'] ? strtotime((string)$p['expira_em']) : ($inicio + SESSAO_DURACAO);
        $restante = max(0, $fim - $agora);
        $decorrido = min(SESSAO_DURACAO, max(0, $agora - $inicio));
    }

    return [
        'id' => (int)$p['id'],
        'modo' => (string)($p['modo'] ?? 'individual'),
        'batalha_id' => (int)($p['batalha_id'] ?? 0),
        'competicao_id' => (int)($p['competicao_id'] ?? 0),
        'controlled_event_id' => (int)($p['controlled_event_id'] ?? 0),
        'desafio_viral_id' => (int)($p['desafio_viral_id'] ?? 0),
        'sessao_id' => (string)$p['sessao_id'],
        'public_token' => (string)($p['public_token'] ?? ''),
        'status' => (string)$p['status'],
        'aura' => round((float)$p['aura_gerada'], 2),
        'pontuacao_total' => round((float)$p['pontuacao_total'], 2),
        'likes' => (int)$p['likes_recebidos'],
        'superlikes' => (int)$p['superlikes_recebidos'],
        'pontuacao_social' => afPontuacaoSocial($p),
        'espectadores' => $pdo ? afContarEspectadores($pdo,(int)$p['id']) : (int)($p['espectadores_ativos'] ?? 0),
        'bonus_combo' => round((float)$p['bonus_combo'], 2),
        'modo_analise' => (string)($p['modo_analise'] ?? 'movimento'),
        'movimento_medio' => round((float)$p['movimento_medio'], 2),
        'movimento_maximo' => round((float)$p['movimento_maximo'], 2),
        'pose_confianca_media' => round((float)($p['pose_confianca_media'] ?? 0), 2),
        'cobertura_corporal_media' => round((float)($p['cobertura_corporal_media'] ?? 0), 2),
        'precisao_media' => round((float)($p['precisao_media'] ?? 0), 2),
        'fluidez_media' => round((float)($p['fluidez_media'] ?? 0), 2),
        'originalidade_media' => round((float)($p['originalidade_media'] ?? 0), 2),
        'dificuldade_media' => round((float)($p['dificuldade_media'] ?? 0), 2),
        'frames_pose' => (int)($p['total_frames_pose'] ?? 0),
        'frames_maos' => (int)($p['total_frames_maos'] ?? 0),
        'movimentos_reconhecidos' => (int)($p['movimentos_reconhecidos'] ?? 0),
        'perfect_moves' => (int)($p['perfect_moves'] ?? 0),
        'combo_atual' => (int)($p['combo_atual'] ?? 1),
        'combo_maximo' => (int)($p['combo_maximo'] ?? 1),
        'combo_hits_atual' => (int)($p['combo_hits_atual'] ?? 0),
        'combo_hits_maximo' => (int)($p['combo_hits_maximo'] ?? 0),
        'combo_quebras' => (int)($p['combo_quebras'] ?? 0),
        'combo_perfect_chain_atual' => (int)($p['combo_perfect_chain_atual'] ?? 0),
        'combo_perfect_chain_max' => (int)($p['combo_perfect_chain_max'] ?? 0),
        'combo_duracao_max_segundos' => (int)($p['combo_duracao_max_segundos'] ?? 0),
        'combo_ritmo_medio' => round((float)($p['combo_ritmo_medio'] ?? 0),2),
        'combo_variedade_max' => (int)($p['combo_variedade_max'] ?? 0),
        'combo_pausas_estrategicas' => (int)($p['combo_pausas_estrategicas'] ?? 0),
        'combo_sequencias_concluidas' => (int)($p['combo_sequencias_concluidas'] ?? 0),
        'bonus_sequencias' => round((float)($p['bonus_sequencias'] ?? 0),2),
        'combo_nota' => (string)($p['combo_nota'] ?? 'D'),
        'ultima_sequencia_combo' => $p['ultima_sequencia_combo'] ?? null,
        'ultimo_movimento' => $p['ultimo_movimento'] ?? null,
        'nivel_aura_maximo' => (int)($p['nivel_aura_maximo'] ?? afNivelAuraNumero((float)$p['aura_gerada'])),
        'evolucoes_aura' => (int)($p['evolucoes_aura'] ?? 0),
        'aura_maxima_atingida_em' => $p['aura_maxima_atingida_em'] ?? null,
        'amostras' => (int)$p['total_amostras'],
        'ultima_sequencia' => (int)$p['ultima_sequencia'],
        'tempo_decorrido' => $decorrido,
        'tempo_restante' => $restante,
        'duracao' => (int)($p['duracao_segundos'] ?: SESSAO_DURACAO),
        'iniciada_em' => $p['iniciada_em'],
        'expira_em' => $p['expira_em'],
        'finalizada_em' => $p['finalizada_em'],
        'antifraude_status' => (string)($p['antifraude_status'] ?? 'pendente'),
        'antifraude_score' => (int)($p['antifraude_score'] ?? 0),
        'pontuacao_validada' => round((float)($p['pontuacao_validada'] ?? 0),2),
        'liveness' => [
            'score'=>round((float)($p['liveness_score'] ?? 0),2),
            'desafios'=>(int)($p['liveness_desafios'] ?? 0),
            'aprovados'=>(int)($p['liveness_aprovados'] ?? 0),
            'challenge'=>!empty($p['liveness_challenge_code']) ? [
                'code'=>(string)$p['liveness_challenge_code'],
                'label'=>(afLivenessCatalogo()[(string)$p['liveness_challenge_code']]['label'] ?? 'CONFIRME SUA PRESENÇA'),
                'nonce'=>(string)($p['liveness_challenge_nonce'] ?? ''),
                'expires_in'=>!empty($p['liveness_challenge_expira']) ? max(0,strtotime((string)$p['liveness_challenge_expira'])-time()) : 0,
            ] : null,
        ],
    ];
}

function afIniciarPartida(PDO $pdo, int $usuarioId, int $dispositivoId, string $fingerprint = '', int $batalhaId = 0, int $competicaoId = 0, string $modo = 'individual', int $controlledEventId = 0): array
{
    afAssertContaPermitida($pdo,$usuarioId,true);
    afRateLimitOrFail($pdo,'match_start',$usuarioId.'|'.clientIp(),6,600,900);
    if ($dispositivoId <= 0) {
        throw new RuntimeException('Dispositivo não identificado. Faça login novamente.');
    }
    if (function_exists('afMinorPolicyContext') && !empty(afMinorPolicyContext($pdo,$usuarioId)['is_minor'])) {
        $policyFeature = $controlledEventId > 0 ? 'controlled_event_farm' : ($batalhaId > 0 ? 'battle' : 'farm');
        $policyContext=$controlledEventId > 0 ? ['controlled_event_id'=>$controlledEventId] : [];
        if($batalhaId>0){$opponentId=afBattleOpponentId($pdo,$batalhaId,$usuarioId);$policyContext=['opponent_user_id'=>$opponentId];}
        $policyDecision = afMinorPolicyDecision($pdo,$usuarioId,$policyFeature,$policyContext,true);
        if (empty($policyDecision['allowed'])) throw new RuntimeException((string)$policyDecision['message']);
    }
    if ($controlledEventId > 0 && function_exists('afControlledEventAssertAccess')) afControlledEventAssertAccess($pdo,$usuarioId,$controlledEventId,true);

    $pdo->beginTransaction();
    try {
        // O lock do usuário impede duas criações simultâneas sem linha ativa para bloquear.
        $lockUsuario = $pdo->prepare('SELECT id FROM af_usuarios WHERE id=? FOR UPDATE');
        $lockUsuario->execute([$usuarioId]);
        $st = $pdo->prepare("SELECT * FROM af_partidas WHERE usuario_id=? AND status IN ('aguardando','em_andamento') ORDER BY id DESC LIMIT 1 FOR UPDATE");
        $st->execute([$usuarioId]);
        $ativa = $st->fetch();

        if ($ativa) {
            $contextoDiferente = ($batalhaId > 0 && (int)($ativa['batalha_id'] ?? 0) !== $batalhaId)
                || ($competicaoId > 0 && (int)($ativa['competicao_id'] ?? 0) !== $competicaoId)
                || ($controlledEventId > 0 && (int)($ativa['controlled_event_id'] ?? 0) !== $controlledEventId);
            if ($contextoDiferente) {
                throw new RuntimeException('Finalize ou cancele sua partida atual antes de entrar em outra competição.');
            }
            if ($ativa['status'] === 'aguardando' || ($ativa['expira_em'] && strtotime((string)$ativa['expira_em']) > time())) {
                $pdo->commit();
                return $ativa;
            }
            // Uma sessão vencida deve ser concluída pelo fluxo oficial antes de abrir outra.
            $pdo->rollBack();
            afFinalizarPartida($pdo, $usuarioId, (string)$ativa['sessao_id'], true);
            return afIniciarPartida($pdo, $usuarioId, $dispositivoId, $fingerprint, $batalhaId, $competicaoId, $modo, $controlledEventId);
        }

        $modo = in_array($modo, ['individual','batalha','torneio','evento'], true) ? $modo : 'individual';
        if ($controlledEventId > 0) {
            afControlledEventAssertAccess($pdo,$usuarioId,$controlledEventId,true);
            $batalhaId=0;$competicaoId=0;$modo='evento';
        } elseif ($batalhaId > 0) {
            [$batalha] = afValidarBatalhaParaPartida($pdo,$batalhaId,$usuarioId);
            $competicaoId = (int)($batalha['competicao_id'] ?? 0);
            $modo = $batalha['tipo'] === 'torneio' ? 'torneio' : 'batalha';
        } elseif ($competicaoId > 0) {
            afValidarEventoParaPartida($pdo,$competicaoId,$usuarioId);
            $modo = 'evento';
        } else {
            $batalhaId = 0; $competicaoId = 0; $modo = 'individual';
        }

        $sessaoId = bin2hex(random_bytes(24));
        $publicToken = afGerarTokenPublico($pdo);
        $shareToken = afNovoShareToken($pdo);
        $fingerprint = substr($fingerprint, 0, 255);
        $fingerprintInfo = afRegistrarVinculoFingerprint($pdo,$usuarioId,$dispositivoId,$fingerprint);
        $st = $pdo->prepare("INSERT INTO af_partidas
            (usuario_id,modo,batalha_id,competicao_id,controlled_event_id,sessao_id,public_token,share_token,dispositivo_id,device_fingerprint,aura_gerada,pontuacao_total,pontuacao_validada,status,antifraude_status,antifraude_score,ip_address,iniciada_em,expira_em,duracao_segundos,criada_em)
            VALUES (?,?,?,?,?,?,?,?,?,?,0,0,0,'aguardando','pendente',?,?,NULL,NULL,?,NOW())");
        $st->execute([$usuarioId, $modo, $batalhaId ?: null, $competicaoId ?: null, $controlledEventId ?: null, $sessaoId, $publicToken, $shareToken, $dispositivoId, $fingerprint ?: null, (int)$fingerprintInfo['risk'], clientIp(), SESSAO_DURACAO]);
        $id = (int)$pdo->lastInsertId();
        if ($batalhaId > 0) {
            $pdo->prepare("UPDATE af_batalha_jogadores SET partida_id=? WHERE batalha_id=? AND usuario_id=?")->execute([$id,$batalhaId,$usuarioId]);
            afBatalhaEvento($pdo,$batalhaId,$usuarioId,'partida_vinculada',['partida_id'=>$id]);
        }

        $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                       VALUES (?,?,'inicio',0,?,NOW())")
            ->execute([$id, $usuarioId, json_encode(['device_id'=>$dispositivoId,'fingerprint_accounts'=>$fingerprintInfo['accounts'],'initial_risk'=>$fingerprintInfo['risk'],'controlled_event_id'=>$controlledEventId], JSON_UNESCAPED_UNICODE)]);

        $pdo->commit();
        $st = $pdo->prepare('SELECT * FROM af_partidas WHERE id=?');
        $st->execute([$id]);
        return $st->fetch();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}


function afAtivarPartida(PDO $pdo, int $usuarioId, int $dispositivoId, string $sessaoId): array
{
    $pdo->beginTransaction();
    try {
        $p = afBuscarPartidaPorSessao($pdo, $usuarioId, $sessaoId, true);
        if (!$p) {
            throw new RuntimeException('Partida não encontrada.');
        }
        if ((int)$p['dispositivo_id'] !== $dispositivoId) {
            throw new RuntimeException('Esta partida pertence a outro dispositivo.');
        }
        if ($p['status'] === 'em_andamento') {
            $pdo->commit();
            return $p;
        }
        if ($p['status'] !== 'aguardando') {
            throw new RuntimeException('A partida não pode mais ser iniciada.');
        }
        if (!empty($p['controlled_event_id']) && function_exists('afControlledEventAssertMatchSupervision')) afControlledEventAssertMatchSupervision($pdo,$p);

        if (!empty($p['batalha_id'])) {
            $batalha = afBuscarBatalha($pdo,(int)$p['batalha_id'],true);
            if (!$batalha || $batalha['status'] !== 'em_andamento' || empty($batalha['iniciada_em']) || empty($batalha['expira_em'])) {
                throw new RuntimeException('Aguardando o adversário preparar a câmera.');
            }
            $pdo->prepare("UPDATE af_partidas SET status='em_andamento',iniciada_em=?,expira_em=?,duracao_segundos=?,ultimo_heartbeat=NOW(),atualizada_em=NOW() WHERE id=?")
                ->execute([$batalha['iniciada_em'],$batalha['expira_em'],(int)$batalha['duracao_segundos'],(int)$p['id']]);
            $pdo->prepare("UPDATE af_batalha_jogadores SET status='jogando' WHERE batalha_id=? AND usuario_id=?")->execute([(int)$p['batalha_id'],$usuarioId]);
        } else {
            $pdo->prepare("UPDATE af_partidas SET status='em_andamento',iniciada_em=NOW(),expira_em=DATE_ADD(NOW(),INTERVAL ? SECOND),ultimo_heartbeat=NOW(),atualizada_em=NOW() WHERE id=?")
                ->execute([SESSAO_DURACAO, (int)$p['id']]);
        }
        $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'camera_pronta',0,NULL,NOW())")
            ->execute([(int)$p['id'], $usuarioId]);
        $pdo->commit();

        $st = $pdo->prepare('SELECT * FROM af_partidas WHERE id=?');
        $st->execute([(int)$p['id']]);
        $started=$st->fetch();
        if($started && function_exists('afGuardianDashboardCreateAlert')){
            afGuardianDashboardCreateAlert($pdo,$usuarioId,'session_start','info','Sessão iniciada','O dependente iniciou uma sessão protegida.',['modo'=>$started['modo']??'individual'],'partida',(int)$started['id']);
        }
        return $started;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}

function afClampMetric(mixed $value): float
{
    return max(0.0, min(100.0, (float)$value));
}

function afComboMultiplicador(int $hits): float
{
    return match (true) {
        $hits >= 8 => 5.00,
        $hits >= 5 => 3.00,
        $hits >= 3 => 2.00,
        default => 1.00,
    };
}

function afComboNota(int $hitsMaximos, float $ritmo, int $variedade, int $perfectChain): string
{
    $score = min(40, $hitsMaximos * 5) + min(25, $ritmo * .25) + min(20, $variedade * 4) + min(15, $perfectChain * 3);
    return match (true) {
        $score >= 90 => 'S',
        $score >= 76 => 'A',
        $score >= 60 => 'B',
        $score >= 42 => 'C',
        default => 'D',
    };
}

function afComboSequenciasCatalogo(): array
{
    return [
        'ascensao_total' => [
            'codigo'=>'ascensao_total','nome'=>'ASCENSÃO TOTAL','icone'=>'🌌',
            'movimentos'=>['power_pose','aura_wings','energy_hands'],'bonus'=>30.0,
            'descricao'=>'Poder Total → Asas de Aura → Mãos de Energia',
        ],
        'quebra_dimensional' => [
            'codigo'=>'quebra_dimensional','nome'=>'QUEBRA DIMENSIONAL','icone'=>'⚔️',
            'movimentos'=>['cross_guard','diagonal_strike','power_pose'],'bonus'=>35.0,
            'descricao'=>'Guarda Cruzada → Corte Diagonal → Poder Total',
        ],
        'renascimento_aura' => [
            'codigo'=>'renascimento_aura','nome'=>'RENASCIMENTO DE AURA','icone'=>'🔥',
            'movimentos'=>['aura_squat','stable_pose','power_pose'],'bonus'=>40.0,
            'descricao'=>'Agachamento → Pose Perfeita → Poder Total',
        ],
        'fluxo_cosmico' => [
            'codigo'=>'fluxo_cosmico','nome'=>'FLUXO CÓSMICO','icone'=>'〰️',
            'movimentos'=>['aura_wave','energy_hands','aura_wings'],'bonus'=>30.0,
            'descricao'=>'Onda de Aura → Mãos de Energia → Asas de Aura',
        ],
    ];
}

function afDetectarSequenciaCombo(array $historico): ?array
{
    $historico = array_values(array_filter(array_map('strval',$historico)));
    foreach (afComboSequenciasCatalogo() as $sequencia) {
        $padrao = $sequencia['movimentos'];
        if (count($historico) >= count($padrao) && array_slice($historico, -count($padrao)) === $padrao) {
            return $sequencia;
        }
    }
    return null;
}

function afHistoricoCombo(string|null $json): array
{
    if (!$json) return [];
    $dados = json_decode($json,true);
    if (!is_array($dados)) return [];
    return array_values(array_slice(array_filter(array_map(static fn($v)=>preg_replace('/[^a-z_]/','',strtolower((string)$v)),$dados)), -8));
}

function afMovimentoPermitido(string $codigo): bool
{
    return in_array($codigo, [
        'power_pose', 'aura_wings', 'diagonal_strike', 'cross_guard',
        'aura_squat', 'energy_hands', 'stable_pose', 'aura_wave'
    ], true);
}

/**
 * Registra uma amostra resumida da visão computacional.
 * Compatível com a Etapa 3: quando modo=movimento, usa apenas intensidade.
 */
function afRegistrarMovimento(
    PDO $pdo,
    int $usuarioId,
    int $dispositivoId,
    string $sessaoId,
    float $intensidade,
    int $sequencia,
    int $tempoCliente,
    array $visao = []
): array {
    $intensidade = afClampMetric($intensidade);
    $sequencia = max(1, $sequencia);
    $tempoCliente = max(0, min(SESSAO_DURACAO + 15, $tempoCliente));

    $modo = (($visao['modo'] ?? '') === 'visao') ? 'visao' : 'movimento';
    $poseDetectada = !empty($visao['pose_detectada']);
    $confianca = afClampMetric($visao['confianca'] ?? 0);
    $cobertura = afClampMetric($visao['cobertura'] ?? 0);
    $precisao = afClampMetric($visao['precisao'] ?? 0);
    $fluidez = afClampMetric($visao['fluidez'] ?? 0);
    $originalidade = afClampMetric($visao['originalidade'] ?? 0);
    $dificuldade = afClampMetric($visao['dificuldade'] ?? 0);
    $maos = max(0, min(2, (int)($visao['maos'] ?? 0)));
    $framesPose = max(0, min(1000, (int)($visao['frames_pose'] ?? 0)));
    $framesMaos = max(0, min(1000, (int)($visao['frames_maos'] ?? 0)));
    $movimento = preg_replace('/[^a-z_]/', '', strtolower((string)($visao['movimento'] ?? '')));
    if (!afMovimentoPermitido($movimento)) {
        $movimento = '';
    }
    $safetyPaused = !empty($visao['safety_paused']);
    $safetyReason = preg_replace('/[^a-z_]/', '', strtolower((string)($visao['safety_reason'] ?? '')));
    $visao['movimento']=$movimento;
    afRateLimitOrFail($pdo,'match_heartbeat',$usuarioId.'|'.$sessaoId,48,60,60);

    $pdo->beginTransaction();
    try {
        $p = afBuscarPartidaPorSessao($pdo, $usuarioId, $sessaoId, true);
        if (!$p) {
            throw new RuntimeException('Partida não encontrada.');
        }
        if ((int)$p['dispositivo_id'] !== $dispositivoId) {
            throw new RuntimeException('Esta partida pertence a outro dispositivo.');
        }
        if ($p['status'] !== 'em_andamento') {
            $pdo->commit();
            return $p;
        }
        if (!empty($p['controlled_event_id']) && function_exists('afControlledEventHasActiveSupervision') && !afControlledEventHasActiveSupervision($pdo,(int)$p['controlled_event_id'])) {
            throw new RuntimeException('Pontuação pausada: a supervisão presencial do evento precisa permanecer ativa.');
        }

        $inicio = strtotime((string)$p['iniciada_em']);
        $decorridoServidor = max(0, time() - $inicio);
        if ($decorridoServidor >= SESSAO_DURACAO + 3) {
            $pdo->rollBack();
            return afFinalizarPartida($pdo, $usuarioId, $sessaoId, true);
        }
        if ($sequencia <= (int)$p['ultima_sequencia']) {
            $pdo->commit();
            return $p;
        }
        if ($sequencia > (int)$p['ultima_sequencia'] + 5) {
            throw new RuntimeException('Sequência de amostras inválida.');
        }
        if (abs($tempoCliente - $decorridoServidor) > 20) {
            throw new RuntimeException('Relógio da sessão fora de sincronia.');
        }
        $ultimoHeartbeat = $p['ultimo_heartbeat'] ? strtotime((string)$p['ultimo_heartbeat']) : 0;
        if ($ultimoHeartbeat && time() - $ultimoHeartbeat < 2) {
            $pdo->commit();
            return $p;
        }

        // Durante uma pausa automática da câmera segura, o relógio da partida
        // continua, mas nenhuma amostra de movimento entra na pontuação ou nas
        // médias antifraude. Apenas a sequência e o heartbeat são sincronizados.
        if ($safetyPaused) {
            $pdo->prepare("UPDATE af_partidas SET ultima_sequencia=?,ultimo_heartbeat=NOW(),atualizada_em=NOW() WHERE id=?")
                ->execute([$sequencia,(int)$p['id']]);
            if ($safetyReason !== '') {
                $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                               SELECT ?,?,'camera_segura_pausa',0,?,NOW()
                               WHERE NOT EXISTS (
                                 SELECT 1 FROM af_partida_eventos
                                 WHERE partida_id=? AND tipo='camera_segura_pausa' AND dados=? AND criado_em>=DATE_SUB(NOW(),INTERVAL 15 SECOND)
                               )")
                    ->execute([(int)$p['id'],$usuarioId,json_encode(['reason'=>$safetyReason],JSON_UNESCAPED_UNICODE),(int)$p['id'],json_encode(['reason'=>$safetyReason],JSON_UNESCAPED_UNICODE)]);
            }
            $p['ultima_sequencia']=$sequencia;
            $p['ultimo_heartbeat']=date('Y-m-d H:i:s');
            $pdo->commit();
            return $p;
        }

        $antifraudeAmostra = afAnalisarAmostraAntifraude($pdo,$p,$tempoCliente,$decorridoServidor,$visao,$intensidade);
        $p['antifraude_score']=$antifraudeAmostra['score'];
        $p['amostras_duplicadas']=$antifraudeAmostra['duplicates'];
        $p['intervalo_amostra_medio']=$antifraudeAmostra['mean'];
        $p['intervalo_amostra_m2']=$antifraudeAmostra['m2'];

        $totalAnterior = (int)$p['total_amostras'];
        $novoTotal = $totalAnterior + 1;
        $media = static fn(float $anterior, float $atual): float => (($anterior * $totalAnterior) + $atual) / max(1, $novoTotal);
        $novaMediaMovimento = $media((float)$p['movimento_medio'], $intensidade);
        $novoMaximo = max((float)$p['movimento_maximo'], $intensidade);

        $novoCombo = 1;
        $movimentoNovo = false;
        $comboQuebrado = false;
        $pausaEstrategica = false;
        $sequenciaEspecial = null;
        $bonusSequenciaAmostra = 0.0;
        $repeticoes = (int)($p['repeticoes_movimento'] ?? 0);
        $ultimoMovimento = (string)($p['ultimo_movimento'] ?? '');
        $ultimoMovimentoEm = !empty($p['ultimo_movimento_em']) ? strtotime((string)$p['ultimo_movimento_em']) : 0;
        $agoraTs = time();
        $intervaloMovimento = $ultimoMovimentoEm ? max(0,$agoraTs-$ultimoMovimentoEm) : 0;
        $comboHits = (int)($p['combo_hits_atual'] ?? 0);
        $comboHitsMax = (int)($p['combo_hits_maximo'] ?? 0);
        $comboBreaks = (int)($p['combo_quebras'] ?? 0);
        $perfectChain = (int)($p['combo_perfect_chain_atual'] ?? 0);
        $perfectChainMax = (int)($p['combo_perfect_chain_max'] ?? 0);
        $comboInicioTs = !empty($p['combo_inicio_em']) ? strtotime((string)$p['combo_inicio_em']) : 0;
        $comboDuracaoMax = (int)($p['combo_duracao_max_segundos'] ?? 0);
        $ritmoAnterior = (float)($p['combo_ritmo_medio'] ?? 0);
        $pausasEstrategicas = (int)($p['combo_pausas_estrategicas'] ?? 0);
        $sequenciasConcluidas = (int)($p['combo_sequencias_concluidas'] ?? 0);
        $bonusSequenciasTotal = (float)($p['bonus_sequencias'] ?? 0);
        $historicoCombo = afHistoricoCombo($p['combo_historico'] ?? null);
        $qualidadeCombo = $precisao >= 48 && $fluidez >= 44 && $confianca >= 45 && $cobertura >= 45;
        $ritmoAtual = 0.0;

        if ($modo === 'visao' && $poseDetectada && $movimento !== '') {
            if ($movimento !== $ultimoMovimento && (!$ultimoMovimentoEm || $intervaloMovimento >= 1)) {
                $movimentoNovo = true;
                $repeticoes = 0;

                if (!$qualidadeCombo) {
                    if ($comboHits > 0) {
                        $comboQuebrado = true;
                        $comboBreaks++;
                        $comboDuracaoMax = max($comboDuracaoMax,$comboInicioTs ? $agoraTs-$comboInicioTs : 0);
                    }
                    $comboHits = 0;
                    $perfectChain = 0;
                    $historicoCombo = [];
                    $comboInicioTs = 0;
                } else {
                    if ($comboHits > 0 && $intervaloMovimento > 6) {
                        $comboQuebrado = true;
                        $comboBreaks++;
                        $comboDuracaoMax = max($comboDuracaoMax,$comboInicioTs ? $agoraTs-$comboInicioTs : 0);
                        $comboHits = 0;
                        $perfectChain = 0;
                        $historicoCombo = [];
                        $comboInicioTs = 0;
                    }
                    if ($comboHits === 0) $comboInicioTs = $agoraTs;
                    $comboHits++;
                    $comboHitsMax = max($comboHitsMax,$comboHits);
                    $ritmoAtual = $ultimoMovimentoEm ? max(0.0,100.0-abs($intervaloMovimento-2.4)*24.0) : 70.0;
                    $ritmoAmostras = max(0,(int)($p['movimentos_reconhecidos'] ?? 0));
                    $ritmoAnterior = (($ritmoAnterior*$ritmoAmostras)+$ritmoAtual)/max(1,$ritmoAmostras+1);
                    if ($intervaloMovimento >= 3 && $intervaloMovimento <= 5 && $precisao >= 70 && $fluidez >= 60) {
                        $pausaEstrategica = true;
                        $pausasEstrategicas++;
                    }
                    $historicoCombo[] = $movimento;
                    $historicoCombo = array_slice($historicoCombo,-8);
                    $sequenciaEspecial = afDetectarSequenciaCombo($historicoCombo);
                    if ($sequenciaEspecial && (string)($p['ultima_sequencia_combo'] ?? '') !== $sequenciaEspecial['codigo']) {
                        $bonusSequenciaAmostra = (float)$sequenciaEspecial['bonus'];
                        $sequenciasConcluidas++;
                        $bonusSequenciasTotal += $bonusSequenciaAmostra;
                    } else {
                        $sequenciaEspecial = null;
                    }
                }
            } else {
                $repeticoes++;
                if ($repeticoes >= 2 && $comboHits > 0) {
                    $comboQuebrado = true;
                    $comboBreaks++;
                    $comboDuracaoMax = max($comboDuracaoMax,$comboInicioTs ? $agoraTs-$comboInicioTs : 0);
                    $comboHits = 0;
                    $perfectChain = 0;
                    $historicoCombo = [];
                    $comboInicioTs = 0;
                }
            }
        } elseif ($comboHits > 0 && $ultimoMovimentoEm && $intervaloMovimento > 6) {
            $comboQuebrado = true;
            $comboBreaks++;
            $comboDuracaoMax = max($comboDuracaoMax,$comboInicioTs ? $agoraTs-$comboInicioTs : 0);
            $comboHits = 0;
            $perfectChain = 0;
            $historicoCombo = [];
            $comboInicioTs = 0;
        }

        $novoCombo = afComboMultiplicador($comboHits);
        $variedadeAtual = count(array_unique($historicoCombo));
        $variedadeMax = max((int)($p['combo_variedade_max'] ?? 0),$variedadeAtual);


        if ($modo === 'visao' && $poseDetectada) {
            // Base de movimento + qualidade. A pontuação é deliberadamente limitada no servidor.
            $baseMovimento = $intensidade < 2.5 ? 0.0 : min(5.5, $intensidade * 0.055);
            $qualidadeBruta = ($precisao * 0.025) + ($fluidez * 0.020) + ($originalidade * 0.018) + ($dificuldade * 0.018);
            // Impede que ficar completamente parado gere pontuação contínua.
            $fatorAtividade = $movimentoNovo ? 1.0 : max(0.05, min(1.0, $intensidade / 18));
            $qualidade = $qualidadeBruta * $fatorAtividade;
            $presenca = max(0.35, min(1.0, (($confianca + $cobertura) / 200)));
            $bonusMovimento = $movimentoNovo ? match ($movimento) {
                'aura_squat', 'stable_pose' => 3.0,
                'power_pose', 'diagonal_strike', 'energy_hands', 'aura_wave' => 2.5,
                default => 2.0,
            } : 0.0;
            $bonusMaos = min(0.8, $maos * 0.4);
            $penalidadeRepeticao = min(0.75, $repeticoes * 0.25);
            $pontosBase = max(0.0, (($baseMovimento + $qualidade + $bonusMovimento + $bonusMaos) * $presenca) * (1 - $penalidadeRepeticao));
            // O multiplicador completo vale quando um novo movimento é reconhecido.
            // Entre movimentos, apenas uma fração permanece para impedir farm parado.
            $multiplicador = $movimentoNovo ? (float)$novoCombo : 1.0 + max(0.0,((float)$novoCombo-1.0)*.15);
            $bonusPausa = $pausaEstrategica ? 2.5 : 0.0;
            $pontosSemSequencia = max(0.0,($pontosBase * $multiplicador) + $bonusPausa);
            $pontos = round(min(60.0, $pontosSemSequencia + $bonusSequenciaAmostra), 2);
            $bonusComboAmostra = max(0.0, round(($pontosSemSequencia - $pontosBase), 2));
        } else {
            // Fallback compatível com celulares sem IA/modelos.
            $pontos = $intensidade < 6 ? 0.0 : round(min(10.0, ($intensidade - 5) * 0.1053), 2);
            $bonusComboAmostra = 0.0;
            $novoCombo = 1;
        }
        if ((int)$antifraudeAmostra['score'] >= ANTIFRAUD_REJECT_SCORE) {
            $pontos = 0.0;
            $bonusComboAmostra = 0.0;
        }

        $auraAtual = (float)$p['aura_gerada'];
        $pontuacaoSocial = afPontuacaoSocial($p);
        $novaAura = min((float)AURA_MAX, $auraAtual + $pontos);
        $nivelAuraAnterior = afNivelAuraNumero($auraAtual);
        $nivelAuraNovo = afNivelAuraNumero($novaAura);
        $evoluiuAura = $nivelAuraNovo >= 2 && $nivelAuraNovo > $nivelAuraAnterior;
        $pontosAplicados = max(0.0, $novaAura - $auraAtual);
        $bonusAplicado = min($bonusComboAmostra, $pontosAplicados);
        $perfect = $movimentoNovo && $qualidadeCombo && $precisao >= 85 && $fluidez >= 78 && $originalidade >= 55 && $dificuldade >= 45;
        if ($movimentoNovo && $qualidadeCombo) {
            $perfectChain = $perfect ? $perfectChain + 1 : 0;
            $perfectChainMax = max($perfectChainMax,$perfectChain);
        }
        if ($comboHits > 0 && $comboInicioTs) $comboDuracaoMax = max($comboDuracaoMax,$agoraTs-$comboInicioTs);
        $comboNota = afComboNota($comboHitsMax,$ritmoAnterior,$variedadeMax,$perfectChainMax);
        $comboInicioPersistido = $comboInicioTs ? date('Y-m-d H:i:s',$comboInicioTs) : null;
        $ultimoComboPersistido = ($movimentoNovo && $qualidadeCombo) ? date('Y-m-d H:i:s',$agoraTs) : ($p['ultimo_combo_em'] ?? null);
        $ultimaSequenciaCodigo = $comboQuebrado ? null : ($sequenciaEspecial['codigo'] ?? ($p['ultima_sequencia_combo'] ?? null));

        $movimentoPersistido = $movimento !== '' ? $movimento : ($p['ultimo_movimento'] ?? null);

        $sql = "UPDATE af_partidas SET
            modo_analise=?,aura_gerada=?,pontuacao_total=?,bonus_combo=bonus_combo+?,bonus_sequencias=?,
            movimento_medio=?,movimento_maximo=?,pose_confianca_media=?,cobertura_corporal_media=?,
            precisao_media=?,fluidez_media=?,originalidade_media=?,dificuldade_media=?,
            total_amostras=?,total_frames_pose=total_frames_pose+?,total_frames_maos=total_frames_maos+?,
            movimentos_reconhecidos=movimentos_reconhecidos+?,perfect_moves=perfect_moves+?,
            combo_atual=?,combo_maximo=GREATEST(combo_maximo,?),combo_hits_atual=?,combo_hits_maximo=?,combo_quebras=?,
            combo_perfect_chain_atual=?,combo_perfect_chain_max=?,combo_duracao_max_segundos=?,combo_ritmo_medio=?,
            combo_variedade_max=?,combo_pausas_estrategicas=?,combo_sequencias_concluidas=?,combo_historico=?,
            combo_inicio_em=?,ultimo_combo_em=?,ultima_sequencia_combo=?,combo_nota=?,ultimo_movimento=?,
            nivel_aura_maximo=GREATEST(nivel_aura_maximo,?),
            evolucoes_aura=evolucoes_aura+?,
            aura_maxima_atingida_em=IF(?=1 AND aura_maxima_atingida_em IS NULL,NOW(),aura_maxima_atingida_em),
            ultimo_movimento_em=IF(?,NOW(),ultimo_movimento_em),repeticoes_movimento=?,
            ultima_sequencia=?,ultimo_heartbeat=NOW(),atualizada_em=NOW()
            WHERE id=?";
        $st = $pdo->prepare($sql);
        $st->execute([
            $modo,$novaAura,round($novaAura+$pontuacaoSocial,2),$bonusAplicado,$bonusSequenciasTotal,
            $novaMediaMovimento,$novoMaximo,
            $media((float)($p['pose_confianca_media'] ?? 0),$confianca),
            $media((float)($p['cobertura_corporal_media'] ?? 0),$cobertura),
            $media((float)($p['precisao_media'] ?? 0),$precisao),
            $media((float)($p['fluidez_media'] ?? 0),$fluidez),
            $media((float)($p['originalidade_media'] ?? 0),$originalidade),
            $media((float)($p['dificuldade_media'] ?? 0),$dificuldade),
            $novoTotal,$framesPose,$framesMaos,
            $movimentoNovo ? 1 : 0,$perfect ? 1 : 0,
            $novoCombo,$novoCombo,$comboHits,$comboHitsMax,$comboBreaks,
            $perfectChain,$perfectChainMax,$comboDuracaoMax,$ritmoAnterior,
            $variedadeMax,$pausasEstrategicas,$sequenciasConcluidas,json_encode($historicoCombo,JSON_UNESCAPED_UNICODE),
            $comboInicioPersistido,$ultimoComboPersistido,$ultimaSequenciaCodigo,$comboNota,$movimentoPersistido,
            $nivelAuraNovo,$evoluiuAura ? 1 : 0,$nivelAuraNovo >= 6 ? 1 : 0,
            $movimentoNovo ? 1 : 0,$repeticoes,$sequencia,(int)$p['id']
        ]);

        $dados = json_encode([
            'modo' => $modo,
            'intensidade' => round($intensidade, 2),
            'pose_detectada' => $poseDetectada,
            'confianca' => round($confianca, 2),
            'cobertura' => round($cobertura, 2),
            'precisao' => round($precisao, 2),
            'fluidez' => round($fluidez, 2),
            'originalidade' => round($originalidade, 2),
            'dificuldade' => round($dificuldade, 2),
            'maos' => $maos,
            'movimento' => $movimento,
            'movimento_novo' => $movimentoNovo,
            'combo' => $novoCombo,
            'combo_hits' => $comboHits,
            'combo_quebrado' => $comboQuebrado,
            'ritmo' => round($ritmoAtual,2),
            'pausa_estrategica' => $pausaEstrategica,
            'sequencia_especial' => $sequenciaEspecial['codigo'] ?? null,
            'perfect' => $perfect,
            'nivel_aura' => $nivelAuraNovo,
            'evoluiu_aura' => $evoluiuAura,
            'sequencia' => $sequencia,
            'tempo_cliente' => $tempoCliente,
            'tempo_servidor' => $decorridoServidor,
        ], JSON_UNESCAPED_UNICODE);
        $tipoEvento = $modo === 'visao' ? 'visao' : 'movimento';
        $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                       VALUES (?,?,?,?,?,NOW())")
            ->execute([(int)$p['id'], $usuarioId, $tipoEvento, $pontosAplicados, $dados]);
        if ($movimentoNovo) {
            $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                           VALUES (?,?,'movimento_especial',?,?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,$pontosAplicados,json_encode(['movimento'=>$movimento,'combo'=>$novoCombo,'combo_hits'=>$comboHits,'perfect'=>$perfect,'ritmo'=>round($ritmoAtual,2),'pausa_estrategica'=>$pausaEstrategica],JSON_UNESCAPED_UNICODE)]);
        }
        if ($comboQuebrado) {
            $pdo->prepare("INSERT INTO af_partida_eventos(partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'combo_quebrado',0,?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,json_encode(['quebras'=>$comboBreaks,'ultimo_movimento'=>$ultimoMovimento],JSON_UNESCAPED_UNICODE)]);
        }
        if ($perfect && $perfectChain >= 2) {
            $pdo->prepare("INSERT INTO af_partida_eventos(partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'perfect_chain',?, ?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,$perfectChain,json_encode(['chain'=>$perfectChain,'movimento'=>$movimento],JSON_UNESCAPED_UNICODE)]);
        }
        if ($sequenciaEspecial) {
            $pdo->prepare("INSERT INTO af_partida_combo_sequencias(partida_id,usuario_id,sequencia_codigo,bonus,combo_hits,criada_em) VALUES (?,?,?,?,?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,$sequenciaEspecial['codigo'],$bonusSequenciaAmostra,$comboHits]);
            $pdo->prepare("INSERT INTO af_partida_eventos(partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'combo_sequencia',?,?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,$bonusSequenciaAmostra,json_encode($sequenciaEspecial,JSON_UNESCAPED_UNICODE)]);
        }
        if ($evoluiuAura) {
            $eventoAura = $nivelAuraNovo >= 6 ? 'aura_maxima' : 'evolucao_aura';
            $nivelVisual = afNivelAuraVisual($novaAura);
            $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                           VALUES (?,?,?,?,?,NOW())")
                ->execute([(int)$p['id'], $usuarioId, $eventoAura, $novaAura, json_encode($nivelVisual, JSON_UNESCAPED_UNICODE)]);
        }

        afEmitirLiveness($pdo,$p,$decorridoServidor);
        $pdo->commit();
        $st = $pdo->prepare('SELECT * FROM af_partidas WHERE id=?');
        $st->execute([(int)$p['id']]);
        return $st->fetch();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}

function afFinalizarPartida(PDO $pdo, int $usuarioId, string $sessaoId, bool $porTempo = false): array
{
    $pdo->beginTransaction();
    try {
        $p = afBuscarPartidaPorSessao($pdo, $usuarioId, $sessaoId, true);
        if (!$p) {
            throw new RuntimeException('Partida não encontrada.');
        }
        if ($p['status'] === 'finalizada') {
            $pdo->commit();
            try {
                if(!empty($p['controlled_event_id']) && function_exists('afControlledEventRecordResult')) afControlledEventRecordResult($pdo,$p);
                else {
                    afRegistrarResultadoBatalha($pdo,$p);
                    afRegistrarResultadoEvento($pdo,$p);
                    afConcluirDesafioViralPorPartida($pdo,$p);
                    afMarcarPrimeiraPartidaIndicacao($pdo,$usuarioId,(int)$p['id']);
                }
            } catch (Throwable $integracaoErro) {
                if (defined('APP_DEBUG') && APP_DEBUG) error_log('Falha ao repetir integração competitiva: '.$integracaoErro->getMessage());
            }
            return $p;
        }
        if ($p['status'] !== 'em_andamento') {
            throw new RuntimeException('A partida não está em andamento.');
        }
        $controlledEventMode=(int)($p['controlled_event_id']??0)>0;
        if($controlledEventMode && function_exists('afControlledEventAssertMatchSupervision')) afControlledEventAssertMatchSupervision($pdo,$p);

        $inicio = strtotime((string)$p['iniciada_em']);
        $decorrido = max(0, time() - $inicio);
        if (!$porTempo && $decorrido < SESSAO_DURACAO - 2) {
            throw new RuntimeException('A sessão ainda não terminou.');
        }

        $stUsuario = $pdo->prepare("SELECT u.melhor_pontuacao,u.xp_total,u.nivel,pr.cidade,pr.estado,COALESCE(NULLIF(TRIM(pr.pais),''),'Brasil') pais
                                    FROM af_usuarios u LEFT JOIN af_perfis pr ON pr.usuario_id=u.id
                                    WHERE u.id=? LIMIT 1 FOR UPDATE");
        $stUsuario->execute([$usuarioId]);
        $dadosUsuario = $stUsuario->fetch() ?: ['melhor_pontuacao'=>0,'cidade'=>null,'estado'=>null,'pais'=>'Brasil'];

        $auraFinal = min((float)AURA_MAX, (float)$p['aura_gerada']);
        $pontuacaoSocial = afPontuacaoSocial($p);
        $pontuacao = round($auraFinal + $pontuacaoSocial, 2);
        $melhorAntes = (float)($dadosUsuario['melhor_pontuacao'] ?? 0);
        $nivelAntes = calcularNivel((int)($dadosUsuario['xp_total'] ?? 0));
        $duracaoReal = min(65535,max(0,$decorrido));
        $segurancaFinal = afAvaliarPartidaFinal($pdo,$p);
        $partidaAprovada = (bool)$segurancaFinal['approved'];
        $sequencia=['bonus_xp'=>0,'sequencia_atual'=>0,'melhor_sequencia'=>0];
        $novoRecorde = false;
        $xpCalculado=['total'=>0,'base'=>0,'bonus'=>0,'detalhes'=>['antifraude'=>$segurancaFinal['status']]];
        $xpGanho=0;$xpBase=0;$xpBonus=0;$novoXpTotal=(int)($dadosUsuario['xp_total']??0);$nivelNovo=$nivelAntes;
        if ($partidaAprovada && !$controlledEventMode) {
            $novoRecorde = $pontuacao > $melhorAntes;
            $sequencia = afAtualizarSequencia($pdo,$usuarioId);
            $xpCalculado = afCalcularXpPartida($p,$pontuacao,$novoRecorde,$auraFinal,(int)$sequencia['bonus_xp']);
            $xpGanho = (int)$xpCalculado['total'];$xpBase=(int)$xpCalculado['base'];$xpBonus=(int)$xpCalculado['bonus'];
            $novoXpTotal=(int)($dadosUsuario['xp_total']??0)+$xpGanho;$nivelNovo=calcularNivel($novoXpTotal);
        }
        $pontuacaoValidada = $partidaAprovada ? $pontuacao : 0.0;
        $shareToken = (string)($p['share_token'] ?? '');
        if (strlen($shareToken)!==16) $shareToken = afNovoShareToken($pdo,(int)$p['id']);

        $pdo->prepare("UPDATE af_partidas SET
              status='finalizada',pontuacao_total=?,pontuacao_validada=?,antifraude_status=?,antifraude_score=?,antifraude_motivos=?,validada_em=IF(?='aprovada',NOW(),NULL),xp_ganho=?,xp_base=?,xp_bonus=?,xp_detalhes=?,melhor_pessoal_antes=?,novo_recorde=?,
              duracao_real_segundos=?,cidade_snapshot=?,estado_snapshot=?,pais_snapshot=?,share_token=?,
              finalizada_em=NOW(),atualizada_em=NOW()
              WHERE id=?")
            ->execute([
                $pontuacao,$pontuacaoValidada,$segurancaFinal['status'],$segurancaFinal['score'],json_encode($segurancaFinal['reasons'],JSON_UNESCAPED_UNICODE),$segurancaFinal['status'],$xpGanho,$xpBase,$xpBonus,json_encode($xpCalculado['detalhes'],JSON_UNESCAPED_UNICODE),$melhorAntes,$novoRecorde?1:0,$duracaoReal,
                ($dadosUsuario['cidade'] ?: null),($dadosUsuario['estado'] ?: null),($dadosUsuario['pais'] ?: 'Brasil'),
                $shareToken,(int)$p['id']
            ]);

        if(!$controlledEventMode){
            $pdo->prepare("UPDATE af_usuarios SET
                total_partidas=total_partidas+1,
                aura_total=aura_total+?,
                total_likes=total_likes+?,
                total_superlikes=total_superlikes+?,
                xp_total=?,nivel=?,titulo=?,
                melhor_pontuacao=GREATEST(melhor_pontuacao,?),
                atualizado_em=NOW()
                WHERE id=?")
                ->execute([$partidaAprovada?(int)floor($auraFinal):0, $partidaAprovada?(int)$p['likes_recebidos']:0, $partidaAprovada?(int)$p['superlikes_recebidos']:0, $novoXpTotal,$nivelNovo,getTitulo($nivelNovo),$partidaAprovada?$pontuacao:0,$usuarioId]);
        }

        $novasRecompensas=[];
        if ($partidaAprovada && !$controlledEventMode) {
        afRegistrarXp($pdo,$usuarioId,$xpGanho,'partida','XP da sessão concluída',(int)$p['id'],null,$xpCalculado['detalhes']);
        $pProgressao=$p;
        $pProgressao['id']=(int)$p['id'];
        afAtualizarMissoesPartida($pdo,$usuarioId,$pProgressao,$pontuacao,$auraFinal);
        $novasRecompensas=afSincronizarRecompensas($pdo,$usuarioId,$nivelNovo,(int)$p['id']);
        if($nivelNovo>$nivelAntes){
            criarNotificacao($pdo,$usuarioId,'level_up','Nível '.$nivelNovo.' alcançado!','Sua progressão avançou e novas recompensas podem estar disponíveis.',['nivel'=>$nivelNovo]);
            $pdo->prepare("INSERT INTO af_partida_eventos(partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'nivel_subiu',?, ?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,$nivelNovo,json_encode(['nivel_anterior'=>$nivelAntes,'nivel_novo'=>$nivelNovo],JSON_UNESCAPED_UNICODE)]);
        }
        $stConquista=$pdo->prepare('SELECT * FROM af_usuarios WHERE id=?');$stConquista->execute([$usuarioId]);
        if($usuarioAtualizado=$stConquista->fetch()) verificarConquistas($pdo,$usuarioAtualizado);
        }

        if ($partidaAprovada && !$controlledEventMode) foreach (['global', 'diario', 'semanal', 'mensal'] as $tipo) {
            $pdo->prepare('INSERT INTO af_ranking (usuario_id,partida_id,pontuacao,tipo,data_registro) VALUES (?,?,?,?,NOW())')
                ->execute([$usuarioId, (int)$p['id'], $pontuacao, $tipo]);
        }

        $posicoes = ($partidaAprovada && !$controlledEventMode) ? [
            'diario' => afCalcularPosicaoPeriodo($pdo,$usuarioId,'diario'),
            'semanal' => afCalcularPosicaoPeriodo($pdo,$usuarioId,'semanal'),
            'mensal' => afCalcularPosicaoPeriodo($pdo,$usuarioId,'mensal'),
            'global' => afCalcularPosicaoPeriodo($pdo,$usuarioId,'global'),
        ] : ['diario'=>0,'semanal'=>0,'mensal'=>0,'global'=>0];
        $pdo->prepare("UPDATE af_partidas SET ranking_diario_posicao=?,ranking_semanal_posicao=?,ranking_mensal_posicao=?,ranking_global_posicao=? WHERE id=?")
            ->execute([$posicoes['diario'],$posicoes['semanal'],$posicoes['mensal'],$posicoes['global'],(int)$p['id']]);

        $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                       VALUES (?,?,'fim',?,?,NOW())")
            ->execute([(int)$p['id'], $usuarioId, $pontuacao, json_encode([
                'xp_ganho'=>$xpGanho,'xp_base'=>$xpBase,'xp_bonus'=>$xpBonus,'xp_detalhes'=>$xpCalculado['detalhes'],
                'aura'=>$auraFinal,'social'=>$pontuacaoSocial,'sequencia'=>$sequencia,
                'novo_recorde'=>$novoRecorde,'melhor_antes'=>$melhorAntes,'posicoes'=>$posicoes,
                'nivel_antes'=>$nivelAntes,'nivel_novo'=>$nivelNovo,'recompensas'=>array_column($novasRecompensas,'codigo'),
                'antifraude'=>$segurancaFinal,
            ], JSON_UNESCAPED_UNICODE)]);

        if (!$partidaAprovada) {
            afLogSeguranca($pdo,'partida_em_analise',(int)$segurancaFinal['score'],['status'=>$segurancaFinal['status'],'motivos'=>$segurancaFinal['reasons']],$usuarioId,(int)$p['id'],(int)($p['dispositivo_id']??0),afFingerprintHash((string)($p['device_fingerprint']??'')));
            $pdo->prepare("INSERT INTO af_partida_eventos(partida_id,usuario_id,tipo,valor,dados,criado_em) VALUES (?,?,'antifraude_alerta',?, ?,NOW())")
                ->execute([(int)$p['id'],$usuarioId,(int)$segurancaFinal['score'],json_encode($segurancaFinal,JSON_UNESCAPED_UNICODE)]);
        }

        criarNotificacao(
            $pdo,
            $usuarioId,
            $controlledEventMode ? 'controlled_event_result' : 'partida_finalizada',
            $controlledEventMode ? 'Resultado do evento registrado' : (!$partidaAprovada ? 'Resultado em análise de segurança' : ($novoRecorde ? 'Novo recorde pessoal!' : 'Farmada concluída!')),
            $controlledEventMode ? ('Sua pontuação de '.number_format($pontuacaoValidada,2,',','.').' foi registrada somente no ranking fechado do evento.') : (!$partidaAprovada ? 'Sua sessão foi salva, mas não entrou no ranking enquanto passa pela validação antifraude.' : ('Você terminou a sessão com ' . number_format($pontuacao, 2, ',', '.') . ' pontos no total.')),
            ['partida_id' => (int)$p['id'], 'sessao_id' => $sessaoId, 'pontuacao' => $pontuacao, 'novo_recorde'=>$novoRecorde,'controlled_event_id'=>(int)($p['controlled_event_id']??0)]
        );

        $pdo->commit();
        recarregarSessaoUsuario($pdo, $usuarioId);
        $st = $pdo->prepare('SELECT * FROM af_partidas WHERE id=?');
        $st->execute([(int)$p['id']]);
        $finalizada = $st->fetch();
        if ($finalizada && ($finalizada['antifraude_status'] ?? '') === 'aprovada') {
            try {
                if(!empty($finalizada['controlled_event_id']) && function_exists('afControlledEventRecordResult')) afControlledEventRecordResult($pdo,$finalizada);
                else {
                    afRegistrarResultadoBatalha($pdo,$finalizada);
                    afRegistrarResultadoEvento($pdo,$finalizada);
                    afConcluirDesafioViralPorPartida($pdo,$finalizada);
                    afMarcarPrimeiraPartidaIndicacao($pdo,$usuarioId,(int)$finalizada['id']);
                }
            } catch (Throwable $integracaoErro) {
                if (defined('APP_DEBUG') && APP_DEBUG) error_log('Falha ao integrar resultado competitivo: '.$integracaoErro->getMessage());
            }
        }
        if($finalizada && function_exists('afGuardianDashboardCreateAlert')){
            afGuardianDashboardCreateAlert($pdo,$usuarioId,'session_end','info','Sessão concluída','O dependente concluiu uma sessão protegida com '.number_format((float)($finalizada['pontuacao_validada']??0),2,',','.').' pontos.',['status'=>$finalizada['antifraude_status']??'','duration'=>(int)($finalizada['duracao_real_segundos']??0)],'partida',(int)$finalizada['id']);
        }
        return $finalizada;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}

function afCancelarPartida(PDO $pdo, int $usuarioId, string $sessaoId): array
{
    $pdo->beginTransaction();
    try {
        $p = afBuscarPartidaPorSessao($pdo, $usuarioId, $sessaoId, true);
        if (!$p) {
            throw new RuntimeException('Partida não encontrada.');
        }
        if ($p['status'] === 'em_andamento') {
            $pdo->prepare("UPDATE af_partidas SET status='cancelada',finalizada_em=NOW(),atualizada_em=NOW() WHERE id=?")
                ->execute([(int)$p['id']]);
            $pdo->prepare("INSERT INTO af_partida_eventos (partida_id,usuario_id,tipo,valor,dados,criado_em)
                           VALUES (?,?,'cancelamento',0,NULL,NOW())")
                ->execute([(int)$p['id'], $usuarioId]);
        }
        $pdo->commit();
        if (!empty($p['batalha_id'])) {
            try {
                $pdo->prepare("UPDATE af_batalha_jogadores SET status='ausente',finalizado_em=NOW() WHERE batalha_id=? AND usuario_id=? AND status<>'finalizado'")
                    ->execute([(int)$p['batalha_id'],$usuarioId]);
                afResolverBatalha($pdo,(int)$p['batalha_id']);
            } catch (Throwable $battleCancelError) {
                if (defined('APP_DEBUG') && APP_DEBUG) error_log($battleCancelError->getMessage());
            }
        }
        $st = $pdo->prepare('SELECT * FROM af_partidas WHERE id=?');
        $st->execute([(int)$p['id']]);
        return $st->fetch();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}
