<?php
// =====================================================
// helpers/avi_token.php — Tokens single-use HMAC para acciones AVI Agent
// =====================================================
//
// Cierra la brecha donde XSS u otro código puede invocar mutation endpoints
// directamente sin pasar por el modal de confirmación. Cuando AVI propone una
// acción, backend emite un token firmado que el endpoint consumidor valida.
//
// Diseño:
// - HMAC-SHA256 con secret del config (DB_NAME + DB_USER + nonce fijo)
// - Payload: action_type|resource_id|user_id|expires_at|jti
// - Single-use: jti se guarda en sesión y se consume al validar
// - Expira en 5 minutos por defecto
//
// XSS attack notes:
//   - Si el atacante tiene la sesión, puede pedir AVI un token y usarlo.
//   - Lo que CIERRA: replay attacks, robo de token vía network sniff,
//     y forja por terceros sin acceso a sesión.
//   - Lo que NO cierra: XSS con control total de sesión activa.
//     Para eso se necesita re-confirmación 2FA o magic link.

if (!function_exists('_dm_avi_token_secret')) {
    function _dm_avi_token_secret(): string {
        return hash('sha256', DB_NAME . DB_USER . 'sc-avi-2026', true);
    }
}

/**
 * Emite un token AVI para una acción específica.
 *
 * @param string $action_type p.ej. 'pause_campaign', 'change_budget'
 * @param string $resource_id ID del recurso (campaign_id, adgroup_id, etc). Opcional.
 * @param int    $ttl_seconds Tiempo de vida (default 300 = 5 min)
 * @return string Token base64-url (incluye payload + HMAC)
 */
function dm_avi_issue_token(string $action_type, string $resource_id = '', int $ttl_seconds = 300): string {
    if (session_status() !== PHP_SESSION_ACTIVE) {
        @session_start();
    }
    $jti        = bin2hex(random_bytes(16));
    $user_id    = (int)($_SESSION['dm_user_id'] ?? 0);
    $expires_at = time() + max(60, $ttl_seconds);
    $payload    = $action_type . '|' . $resource_id . '|' . $user_id . '|' . $expires_at . '|' . $jti;
    $sig        = hash_hmac('sha256', $payload, _dm_avi_token_secret(), true);
    $token      = rtrim(strtr(base64_encode($payload . '::' . $sig), '+/', '-_'), '=');

    // Registro de tokens activos en sesión (cap a 50 más recientes para no inflar)
    if (!isset($_SESSION['avi_tokens']) || !is_array($_SESSION['avi_tokens'])) {
        $_SESSION['avi_tokens'] = [];
    }
    $_SESSION['avi_tokens'][$jti] = ['action'=>$action_type, 'resource'=>$resource_id, 'exp'=>$expires_at, 'used'=>false];
    if (count($_SESSION['avi_tokens']) > 50) {
        $_SESSION['avi_tokens'] = array_slice($_SESSION['avi_tokens'], -50, null, true);
    }
    return $token;
}

/**
 * Valida y CONSUME un token AVI. Devuelve true si es válido (y lo marca usado).
 * Loguea cada intento en error_log para forense.
 */
function dm_avi_validate_token(string $token, string $expected_action_type, string $expected_resource_id = ''): bool {
    if ($token === '') return false;
    if (session_status() !== PHP_SESSION_ACTIVE) @session_start();

    $raw = base64_decode(strtr($token, '-_', '+/'), true);
    if ($raw === false || !str_contains($raw, '::')) {
        error_log("[avi-token] reject malformed token (action={$expected_action_type})");
        return false;
    }
    [$payload, $sig] = explode('::', $raw, 2);
    $expected_sig = hash_hmac('sha256', $payload, _dm_avi_token_secret(), true);
    if (!hash_equals($expected_sig, $sig)) {
        error_log("[avi-token] reject bad-signature (action={$expected_action_type})");
        return false;
    }
    $parts = explode('|', $payload);
    if (count($parts) !== 5) {
        error_log("[avi-token] reject bad-payload (action={$expected_action_type})");
        return false;
    }
    [$action_type, $resource_id, $user_id_str, $expires_str, $jti] = $parts;
    $user_id    = (int)$user_id_str;
    $expires_at = (int)$expires_str;
    $session_user = (int)($_SESSION['dm_user_id'] ?? 0);

    // Match action type
    if ($action_type !== $expected_action_type) {
        error_log("[avi-token] reject action-mismatch (got={$action_type} expected={$expected_action_type})");
        return false;
    }
    // Match resource_id si se especificó al validar (atado al recurso)
    if ($expected_resource_id !== '' && $resource_id !== '' && $expected_resource_id !== $resource_id) {
        error_log("[avi-token] reject resource-mismatch (got={$resource_id} expected={$expected_resource_id})");
        return false;
    }
    // Match user
    if ($user_id !== $session_user) {
        error_log("[avi-token] reject user-mismatch (token_user={$user_id} session_user={$session_user})");
        return false;
    }
    // Expiración
    if (time() > $expires_at) {
        error_log("[avi-token] reject expired (jti={$jti})");
        return false;
    }
    // Single-use: verificar en sesión que no se haya consumido
    $tokens = $_SESSION['avi_tokens'] ?? [];
    if (!isset($tokens[$jti])) {
        error_log("[avi-token] reject unknown-jti ({$jti}) — quizá sesión reiniciada");
        return false;
    }
    if (!empty($tokens[$jti]['used'])) {
        error_log("[avi-token] reject already-used (jti={$jti})");
        return false;
    }

    // CONSUMIR
    $_SESSION['avi_tokens'][$jti]['used'] = true;
    $_SESSION['avi_tokens'][$jti]['used_at'] = time();
    error_log("[avi-token] OK consumed (action={$action_type} resource={$resource_id} user={$user_id} jti={$jti})");
    return true;
}

/**
 * Helper que devuelve false si un endpoint recibió un `avi_token` mal validado.
 * Si NO viene `avi_token` en el request, devuelve true (backwards-compat con UI directa).
 * Si viene, lo valida y consume — false si inválido.
 */
function dm_avi_check_request_token(array $input, string $expected_action, string $expected_resource = ''): bool {
    $tok = (string)($input['avi_token'] ?? '');
    if ($tok === '') return true; // origen NO-AVI (UI directa) — endpoint maneja CSRF normal
    // FASE 8 FIX: si el body envía `avi_action`, ÉSE es el action_type que el AVI
    // emitió y firmó el token con. El `$expected_action` que pasa el endpoint puede
    // diferir (ej: endpoint usa 'update_budget' pero AVI emitió 'change_budget').
    // Usamos avi_action cuando exista — es lo que se firmó.
    $action_for_check = (string)($input['avi_action'] ?? '') ?: $expected_action;
    return dm_avi_validate_token($tok, $action_for_check, $expected_resource);
}
