<?php
// includes/ajax-functions.php

/**
 * Endpoints AJAX para o 1MZ For Artists.
 * Permite verificações assíncronas de saldo, upload de ficheiros e validações.
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Acesso direto bloqueado
}

/**
 * ==========================================================================
 * 1. UPLOAD DE MÚSICA VIA AJAX (Assíncrono e Seguro)
 * ==========================================================================
 */
function onemz_handle_file_upload_ajax() {
    // Inicializar variáveis (evita "Undefined variable" em WP_DEBUG estrito)
    $validation_warnings = [];
    $validation_info     = [];

    // ──────────────────────────────────────────────────────────────────────
    // DIAGNÓSTICO — escrever no error_log para podermos saber onde para.
    // Se o handler nem chega a correr, o problema é registo da action ou
    // erro fatal antes do handler. Se chega aqui, vemos exactamente em que
    // verificação falha.
    //
    // Ver logs em /home/jmetano/public_html/test.1musicmoz.com/wp-content/debug.log
    // (ou no error_log do PHP, dependendo da config do servidor).
    //
    // Para activar logs em wp-config.php:
    //   define( 'WP_DEBUG', true );
    //   define( 'WP_DEBUG_LOG', true );
    //   define( 'WP_DEBUG_DISPLAY', false );
    // ──────────────────────────────────────────────────────────────────────
    error_log('[1MZ-UPLOAD] === handler invocado ===');
    error_log('[1MZ-UPLOAD] POST keys: ' . implode(', ', array_keys($_POST)));
    error_log('[1MZ-UPLOAD] FILES keys: ' . implode(', ', array_keys($_FILES)));
    error_log('[1MZ-UPLOAD] nonce recebido: ' . substr( $_POST['nonce'] ?? '(vazio)', 0, 20 ));

    // 1. Verificar Segurança (Nonce)
    if ( ! check_ajax_referer('onemz_music_upload_nonce', 'nonce', false) ) {
        error_log('[1MZ-UPLOAD] FALHOU: nonce inválido');
        wp_send_json_error('Erro de segurança. Por favor, recarregue a página e tente novamente.');
        return;
    }
    error_log('[1MZ-UPLOAD] nonce OK');
    
    // 2. Verificar se o utilizador está logado
    if ( ! function_exists('rmbz_is_user_logged_in') || ! rmbz_is_user_logged_in() ) {
        error_log('[1MZ-UPLOAD] FALHOU: sessão expirada');
        wp_send_json_error('Sessão expirada. Por favor, faça login novamente.');
        return;
    }
    error_log('[1MZ-UPLOAD] sessão OK');

    // 2.1 Rate limit (5 uploads/min por artista — ficheiros podem ter até 200MB)
    $telemovel = rmbz_get_current_user_telemovel();
    error_log('[1MZ-UPLOAD] telemovel: ' . $telemovel);
    if ( function_exists('rmbz_rate_limit') && ! rmbz_rate_limit( 'upload_music_' . $telemovel, 5 ) ) {
        error_log('[1MZ-UPLOAD] FALHOU: rate limit excedido');
        wp_send_json_error('Demasiados uploads num curto período. Tenta novamente dentro de 1 minuto.');
        return;
    }
    error_log('[1MZ-UPLOAD] rate limit OK');

    // 3. Verificar se o ficheiro chegou
    if ( empty($_FILES['music_file']) ) {
        error_log('[1MZ-UPLOAD] FALHOU: $_FILES[music_file] vazio. POST_MAX_SIZE: ' . ini_get('post_max_size') . ', UPLOAD_MAX_FILESIZE: ' . ini_get('upload_max_filesize'));
        wp_send_json_error('Nenhum ficheiro recebido. Verifica se o ficheiro não excede ' . ini_get('upload_max_filesize') . ' (limite do servidor).');
        return;
    }

    $file = $_FILES['music_file'];

    // 4. Verificar Erros do PHP (ex: tamanho excedido no servidor, upload interrompido)
    if ( $file['error'] !== UPLOAD_ERR_OK ) {
        $msg = 'Erro no upload.';
        switch ($file['error']) {
            case UPLOAD_ERR_INI_SIZE:
            case UPLOAD_ERR_FORM_SIZE:
                $msg = 'O ficheiro é demasiado grande (Limite do servidor).';
                break;
            case UPLOAD_ERR_PARTIAL:
                $msg = 'O upload foi interrompido.';
                break;
            case UPLOAD_ERR_NO_FILE:
                $msg = 'Nenhum ficheiro enviado.';
                break;
            case UPLOAD_ERR_NO_TMP_DIR:
                $msg = 'Pasta temporária em falta no servidor.';
                break;
            case UPLOAD_ERR_CANT_WRITE:
                $msg = 'Falha ao escrever no disco.';
                break;
            default:
                $msg = 'Erro desconhecido no upload (Código ' . $file['error'] . ').';
        }
        wp_send_json_error($msg);
        return;
    }


// 5.1 Validar tamanho (até 200MB para WAV/FLAC de estúdio, 30MB só não chega)
$is_distro_track = isset($_POST['is_distro_track']) && $_POST['is_distro_track'] === '1';
$max_size = $is_distro_track ? (200 * 1024 * 1024) : (30 * 1024 * 1024);
if ( isset($file['size']) && (int) $file['size'] > $max_size ) {
    wp_send_json_error(
        $is_distro_track
            ? 'Ficheiro demasiado grande. Máximo: 200MB.'
            : 'Ficheiro demasiado grande. Máximo: 30MB.'
    );
    return;
}

// 5.2 Validar tipo real do ficheiro (não confiar apenas em $file['type'])
// Para faixas de distribuição aceitamos WAV, FLAC e MP3. Para restante, só MP3.
if ( $is_distro_track ) {
    $allowed_mimes_map = [
        'mp3'  => 'audio/mpeg',
        'wav'  => 'audio/wav',
        'flac' => 'audio/flac',
    ];
    $allowed_mimes = ['audio/mpeg', 'audio/mp3', 'audio/mpeg3', 'audio/x-mpeg-3',
                      'audio/wav', 'audio/wave', 'audio/x-wav',
                      'audio/flac', 'audio/x-flac'];
} else {
    $allowed_mimes_map = [ 'mp3' => 'audio/mpeg' ];
    $allowed_mimes = ['audio/mpeg', 'audio/mp3', 'audio/mpeg3', 'audio/x-mpeg-3'];
}

if ( ! function_exists('wp_check_filetype_and_ext') ) {
    require_once( ABSPATH . 'wp-admin/includes/file.php' );
}

$checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowed_mimes_map);
if ( empty($checked['ext']) || empty($checked['type']) ) {
    wp_send_json_error(
        $is_distro_track
            ? 'Formato inválido. Envie apenas WAV, FLAC ou MP3.'
            : 'Formato inválido. Envie apenas MP3.'
    );
    return;
}

    // 5. Validar MIME type detectado
    $file_type = isset($checked['type']) ? $checked['type'] : (isset($file['type']) ? $file['type'] : '');

    if ( ! in_array($file_type, $allowed_mimes) ) {
        wp_send_json_error('Formato não suportado (' . esc_html($file_type) . ').');
        return;
    }

    // 5.3 VALIDAÇÃO DE QUALIDADE DE ÁUDIO (só para distribuição)
    if ( $is_distro_track && function_exists('rmbz_validate_audio_file') ) {
        $validation = rmbz_validate_audio_file( $file['tmp_name'] );
        if ( ! $validation['ok'] ) {
            $errors_txt = implode(' | ', $validation['errors']);
            wp_send_json_error([
                'message'    => 'Áudio não cumpre os requisitos: ' . $errors_txt,
                'errors'     => $validation['errors'],
                'warnings'   => $validation['warnings'],
                'info'       => $validation['info'],
                'validation' => $validation,
            ]);
            return;
        }
        // Validação passou — guardar warnings para devolver com sucesso
        $validation_warnings = $validation['warnings'];
        $validation_info     = $validation['info'];
    }

    // 6. SANITIZAÇÃO E RENOMEAÇÃO (Segurança Crítica)
    // Preservar extensão correcta (wav/flac/mp3)
    $ext = $checked['ext']; // já validado acima
    // wp_generate_password (usa random_bytes / CSPRNG quando disponível) — mais seguro que md5+rand
    $hash_name = wp_generate_password( 24, false ) . '.' . $ext;

    // Substitui o nome original pelo nome seguro
    $file['name'] = $hash_name;

    // 7. Processar Upload com a API do WordPress
    if ( ! function_exists( 'wp_handle_upload' ) ) {
        require_once( ABSPATH . 'wp-admin/includes/file.php' );
    }

    $upload_overrides = ['test_form' => false];
    $movefile = wp_handle_upload( $file, $upload_overrides );

    if ( $movefile && ! isset( $movefile['error'] ) ) {
        // SUCESSO
        //
        // FIX de segurança (IDOR #2): em vez de devolver a URL crua ao cliente
        // e depois esperar que o cliente ma envie de volta no save_distro_track
        // (o que permitia a qualquer artista passar a URL de outro artista ou
        // uma URL externa), geramos um TOKEN opaco. O save_distro_track só aceita
        // tokens emitidos por este handler, para este telemóvel, e consome-os
        // (one-shot). A URL fica guardada no servidor.
        //
        // Retrocompatibilidade: devolvemos `url` E `upload_token`. Clientes
        // antigos continuam a ver `url` mas o handler do lado servidor vai
        // preferir `upload_token` quando disponível.

        $upload_token = wp_generate_password( 40, false, false );
        set_transient( 'rmbz_upl_' . $upload_token, [
            'url'        => $movefile['url'],
            'telemovel'  => $telemovel,
            'size'       => isset($file['size']) ? (int) $file['size'] : 0,
            'audio_info' => $validation_info,
            'issued_at'  => time(),
        ], 30 * MINUTE_IN_SECONDS );

        $response = [
            'url'          => $movefile['url'],   // manter por compatibilidade
            'upload_token' => $upload_token,      // novo — o save deve preferir isto
            'message'      => 'Upload concluído com sucesso!',
        ];
        if ( ! empty($validation_warnings) ) {
            $response['warnings'] = $validation_warnings;
        }
        if ( ! empty($validation_info) ) {
            $response['audio_info'] = $validation_info;
        }
        wp_send_json_success($response);
    } else {
        // ERRO DO WORDPRESS AO MOVER — não expor $movefile['error'] directamente ao cliente
        error_log( '[1MZ] wp_handle_upload error: ' . ( $movefile['error'] ?? 'unknown' ) );
        wp_send_json_error('Erro no servidor ao guardar o ficheiro. Tenta novamente.');
    }
}
// Registamos para ambos para capturar sessões expiradas e dar erro legível
add_action('wp_ajax_onemz_upload_music_chunk', 'onemz_handle_file_upload_ajax');
add_action('wp_ajax_nopriv_onemz_upload_music_chunk', 'onemz_handle_file_upload_ajax');


/**
 * ==========================================================================
 * 2. VERIFICAR ESTADO DO ARTISTA (Saldo e Subscrição)
 * ==========================================================================
 */
function onemz_check_artist_status_ajax() {
    if ( ! check_ajax_referer( 'onemz_check_status_nonce', 'nonce', false ) ) {
         wp_send_json_error(['message' => 'Segurança inválida.']);
         return;
    }

    if ( ! isset($_POST['telemovel']) ) {
        wp_send_json_error(['message' => 'Telemóvel não fornecido.']);
        return;
    }

    // FIX de segurança (#6): este endpoint era um oráculo — devolvia nome e
    // SALDO DA CARTEIRA a qualquer pessoa com um nonce do frontend. Alguém com
    // uma lista de telemóveis de MZ podia enumerar todos os artistas 1MZ e
    // saber o saldo da carteira deles.
    //
    // Nova política:
    //   - Rate limit por IP (anti-enumeração).
    //   - Resposta detalhada (nome, saldo, subscrição) só se:
    //       (a) o artista está logado com sessão própria do plugin, E
    //       (b) o telemóvel consultado é o próprio.
    //     Ou o utilizador é admin (current_user_can manage_options).
    //   - Caso contrário devolve apenas { exists: bool } — suficiente para
    //     validar telemóveis em formulários de checkout/referral.
    $telemovel = preg_replace('/[^0-9+]/', '', sanitize_text_field( wp_unslash( $_POST['telemovel'] ) ) );
    if ( empty($telemovel) ) {
        wp_send_json_error(['message' => 'Telemóvel inválido.']);
        return;
    }

    // Rate limit anti-enumeração — por IP (15/min é generoso para uso legítimo)
    if ( function_exists('rmbz_rate_limit') ) {
        $ip_hash = md5( $_SERVER['REMOTE_ADDR'] ?? 'unknown' );
        if ( ! rmbz_rate_limit( 'check_status_ip_' . $ip_hash, 15 ) ) {
            wp_send_json_error(['message' => 'Demasiadas verificações. Aguarda um minuto.']);
            return;
        }
    }

    global $wpdb;
    $wallet_table = $wpdb->prefix . 'rmbz_wallets';
    $user_wallet = $wpdb->get_row( $wpdb->prepare( "SELECT user_nome, balance FROM $wallet_table WHERE user_telemovel = %s", $telemovel ) );

    // Política de divulgação
    $can_see_details = false;
    if ( current_user_can('manage_options') ) {
        $can_see_details = true;
    } elseif ( function_exists('rmbz_is_user_logged_in') && rmbz_is_user_logged_in()
               && function_exists('rmbz_get_current_user_telemovel')
               && rmbz_get_current_user_telemovel() === $telemovel ) {
        $can_see_details = true;
    }

    if ( ! $user_wallet ) {
        wp_send_json_success([
            'exists' => false,
        ]);
        return;
    }

    // Resposta mínima — nunca expõe saldo publicamente
    $response_data = [
        'exists' => true,
    ];

    if ( $can_see_details ) {
        $response_data['user_nome']              = $user_wallet->user_nome;
        $response_data['balance']                = (float) $user_wallet->balance;
        $response_data['has_subscription']       = false;
        $response_data['subscription_days_left'] = 0;

        if ( function_exists('rmbz_user_has_active_subscription') ) {
            $has_sub = rmbz_user_has_active_subscription($telemovel);
            $response_data['has_subscription'] = $has_sub;

            if ( $has_sub && function_exists('rmbz_get_subscription_expiry') ) {
                $expiry = rmbz_get_subscription_expiry($telemovel);
                $days_left = ceil((strtotime($expiry) - time()) / 86400);
                $response_data['subscription_days_left'] = max(0, $days_left);
            }
        }
    }

    wp_send_json_success($response_data);
}
add_action( 'wp_ajax_onemz_check_artist_status', 'onemz_check_artist_status_ajax' );
add_action( 'wp_ajax_nopriv_onemz_check_artist_status', 'onemz_check_artist_status_ajax' );


/**
 * ==========================================================================
 * 3. VALIDAR CÓDIGO DE CONVITE (Afiliados)
 * ==========================================================================
 */
function rmbz_validate_referral_code_ajax() {
    if ( ! check_ajax_referer( 'rmbz_validate_referral_nonce', 'nonce', false ) ) {
        wp_send_json_error(['message' => 'Erro segurança.']);
        return;
    }

    if ( !isset($_POST['referral_phone']) ) {
        wp_send_json_error(['message' => 'Dados incompletos.']);
        return;
    }

    // Rate limit por IP (anti-enumeração de telemóveis)
    if ( function_exists('rmbz_rate_limit') ) {
        $ip_hash = md5( $_SERVER['REMOTE_ADDR'] ?? 'unknown' );
        if ( ! rmbz_rate_limit( 'referral_ip_' . $ip_hash, 10 ) ) {
            wp_send_json_error(['message' => 'Demasiadas tentativas. Aguarda um minuto.']);
            return;
        }
    }

    // [1.5.133] Normalização: aceitar números sem o prefixo +258 (utilizador
    // pode escrever só "84xxxxxxx"). Se não tem +258, adicionamos.
    $raw = sanitize_text_field( wp_unslash($_POST['referral_phone']) );
    $referral_phone = preg_replace('/\s+/', '', $raw);
    if ( strpos($referral_phone, '+258') !== 0 ) {
        $referral_phone = '+258' . ltrim($referral_phone, '+');
    }

    // buyer_phone é opcional no contexto de registo novo (a conta do
    // próprio utilizador ainda não existe). Se vier, validamos auto-ref.
    $buyer_phone = isset($_POST['buyer_phone']) ? sanitize_text_field($_POST['buyer_phone']) : '';

    if ( $buyer_phone && $referral_phone === $buyer_phone ) {
        wp_send_json_success(['valid' => false, 'message' => 'Não pode usar o seu próprio número.']);
        return;
    }

    // Validação básica de formato (mín. 11 caracteres com prefixo +258)
    if ( strlen($referral_phone) < 11 ) {
        wp_send_json_success(['valid' => false, 'message' => 'Número incompleto.']);
        return;
    }

    // [1.5.133] Lista negra (banidos de receber comissão)
    $blacklist = (array) get_option('rmbz_referral_blacklist', []);
    if ( in_array($referral_phone, $blacklist, true) ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Este utilizador não está autorizado a receber convites.'
        ]);
        return;
    }

    global $wpdb;
    $wallet_table = $wpdb->prefix . 'rmbz_wallets';
    $convidador = $wpdb->get_row( $wpdb->prepare(
        "SELECT user_nome, is_banned FROM $wallet_table WHERE user_telemovel = %s",
        $referral_phone
    ) );

    if ( ! $convidador ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Número não está registado na 1MZ.'
        ]);
        return;
    }

    // [1.5.133] Conta banida do sistema também não pode receber convites
    if ( ! empty($convidador->is_banned) ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Este utilizador não está autorizado a receber convites.'
        ]);
        return;
    }

    $nome_convidador = !empty($convidador->user_nome) ? $convidador->user_nome : 'Utilizador';
    wp_send_json_success([
        'valid'   => true,
        'message' => '✓ Convite aprovado — ' . esc_html($nome_convidador) . ' vai receber recompensa.',
        'name'    => $nome_convidador,
    ]);
}
add_action( 'wp_ajax_rmbz_validate_referral', 'rmbz_validate_referral_code_ajax' );
add_action( 'wp_ajax_nopriv_rmbz_validate_referral', 'rmbz_validate_referral_code_ajax' );


/**
 * [1.5.137] AJAX para validação ao vivo de cupão de registo VIP.
 *
 * Espelha EXACTAMENTE as mesmas regras do form-handler.php (linhas 253-271)
 * para que se o AJAX devolver "válido", o submit também passe. Se aqui
 * mudar uma regra, o handler do submit também tem de mudar (e vice-versa).
 *
 * Regras (todas têm de passar):
 *   1. Cupão tem de existir em rmbz_coupons
 *   2. Status = 'ativo'
 *   3. Não expirado (se tiver expiry_date)
 *   4. Não atingiu usage_limit (se houver limite)
 *   5. reward_type = 'vip_days' (não aceitar 'saldo' no registo)
 *
 * Devolve JSON com {valid, message, days?} para feedback no frontend.
 */
function rmbz_validate_registration_coupon_ajax() {
    if ( ! check_ajax_referer( 'rmbz_validate_coupon_nonce', 'nonce', false ) ) {
        wp_send_json_error([ 'message' => 'Sessão expirada.' ], 403 );
    }

    $code = isset($_POST['coupon_code'])
        ? strtoupper( sanitize_text_field( str_replace(' ', '', wp_unslash( $_POST['coupon_code'] )) ) )
        : '';

    if ( $code === '' ) {
        wp_send_json_success([ 'valid' => false, 'message' => 'Código vazio.' ]);
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_coupons';
    $coupon = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$table} WHERE coupon_code = %s",
        $code
    ));

    if ( ! $coupon ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Cupão inválido ou não existe.',
        ]);
    }

    if ( $coupon->status !== 'ativo' ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Este cupão está inactivo.',
        ]);
    }

    if ( ! empty($coupon->expiry_date) && strtotime($coupon->expiry_date) < time() ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Este cupão expirou.',
        ]);
    }

    if ( $coupon->usage_limit > 0 && $coupon->used_count >= $coupon->usage_limit ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Limite de usos deste cupão atingido.',
        ]);
    }

    if ( $coupon->reward_type !== 'vip_days' ) {
        wp_send_json_success([
            'valid'   => false,
            'message' => 'Apenas cupões de "Dias VIP" são válidos no registo. Cupões de saldo não são aceites.',
        ]);
    }

    // Tudo OK
    $days = intval( $coupon->reward_value );
    wp_send_json_success([
        'valid'   => true,
        'days'    => $days,
        'message' => sprintf(
            '✓ Cupão válido — %d dias VIP grátis ao criar a conta.',
            $days
        ),
    ]);
}
add_action( 'wp_ajax_rmbz_validate_registration_coupon', 'rmbz_validate_registration_coupon_ajax' );
add_action( 'wp_ajax_nopriv_rmbz_validate_registration_coupon', 'rmbz_validate_registration_coupon_ajax' );


/**
 * [1.5.140] Endpoint AJAX para paginação do Extrato da Carteira.
 *
 * Recebe `page` (1-based) e devolve JSON com:
 *   - rows_html      : HTML das linhas <tr>...</tr> (vai para dentro do tbody)
 *   - pagination_html: HTML da nova paginação (vai substituir a div actual)
 *   - counter_html   : HTML do contador "A mostrar X-Y de Z transações"
 *   - page           : página actual (devolvida para confirmação)
 *
 * Não-logged users → 0 (security guard explícito).
 * Apenas o utilizador autenticado vê o seu próprio extrato.
 */
function rmbz_extrato_paginate_ajax() {
    if ( ! check_ajax_referer( 'rmbz_extrato_paginate_nonce', 'nonce', false ) ) {
        wp_send_json_error( [ 'message' => 'Erro de segurança.' ] );
        return;
    }

    if ( ! function_exists('rmbz_is_user_logged_in') || ! rmbz_is_user_logged_in() ) {
        wp_send_json_error( [ 'message' => 'Sessão expirada. Recarregue a página.' ] );
        return;
    }

    $user_telemovel = function_exists('rmbz_get_current_user_telemovel')
        ? rmbz_get_current_user_telemovel()
        : '';
    if ( empty($user_telemovel) ) {
        wp_send_json_error( [ 'message' => 'Utilizador não identificado.' ] );
        return;
    }

    global $wpdb;
    $table_transactions = $wpdb->prefix . 'rmbz_wallet_transactions';

    $ext_per_page = 15;
    $page         = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
    $offset       = ($page - 1) * $ext_per_page;

    $total_transactions = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$table_transactions} WHERE user_telemovel = %s",
        $user_telemovel
    ) );
    $total_ext_pages = max(1, (int) ceil( $total_transactions / $ext_per_page ));

    // Clamp se a página pedida é maior que o total
    if ( $page > $total_ext_pages ) {
        $page   = $total_ext_pages;
        $offset = ($page - 1) * $ext_per_page;
    }

    $transactions = $wpdb->get_results( $wpdb->prepare(
        "SELECT * FROM {$table_transactions} WHERE user_telemovel = %s ORDER BY transaction_date DESC LIMIT %d OFFSET %d",
        $user_telemovel, $ext_per_page, $offset
    ) );

    // Render rows
    ob_start();
    foreach ( (array) $transactions as $tx ) {
        $color = ($tx->amount >= 0) ? '#2ecc71' : '#e74c3c';
        ?>
        <tr style="border-bottom:1px solid #333;">
            <td data-label="Data" style="padding:10px 0;"><?php echo esc_html( date('d/m/y', strtotime($tx->transaction_date)) ); ?></td>
            <td data-label="Movimento" style="font-size:12px;"><?php echo esc_html($tx->details ?? ''); ?></td>
            <td data-label="Valor" style="color:<?php echo esc_attr($color); ?>;font-weight:bold;"><?php echo esc_html( number_format((float) $tx->amount, 2, ',', '.') ); ?> MT</td>
        </tr>
        <?php
    }
    $rows_html = ob_get_clean();

    // Counter
    $first_idx = $offset + 1;
    $last_idx  = min( $offset + count($transactions), $total_transactions );
    $counter_html = sprintf(
        /* translators: 1: from, 2: to, 3: total */
        esc_html__( 'A mostrar %1$d–%2$d de %3$d transações', '1mz-for-artists' ),
        (int) $first_idx, (int) $last_idx, (int) $total_transactions
    );

    // Pagination
    $pagination_html = rmbz_extrato_render_pagination_html( $page, $total_ext_pages );

    wp_send_json_success([
        'rows_html'       => $rows_html,
        'pagination_html' => $pagination_html,
        'counter_html'    => $counter_html,
        'page'            => $page,
    ]);
}
add_action( 'wp_ajax_rmbz_extrato_paginate', 'rmbz_extrato_paginate_ajax' );
add_action( 'wp_ajax_nopriv_rmbz_extrato_paginate', 'rmbz_extrato_paginate_ajax' );


/**
 * [1.5.140] Renderiza o HTML dos botões de paginação.
 * Reusada pelo render inicial PHP e pelo endpoint AJAX para garantir
 * que ambos produzem markup idêntico.
 */
function rmbz_extrato_render_pagination_html( $current, $total ) {
    if ( $total <= 1 ) return '';

    // Truncação inteligente
    $pages_to_show = [];
    if ( $total <= 7 ) {
        for ( $i = 1; $i <= $total; $i++ ) $pages_to_show[] = $i;
    } else {
        $pages_to_show[] = 1;
        if ( $current > 3 ) $pages_to_show[] = '...';
        for ( $i = max(2, $current - 1); $i <= min($total - 1, $current + 1); $i++ ) {
            $pages_to_show[] = $i;
        }
        if ( $current < $total - 2 ) $pages_to_show[] = '...';
        $pages_to_show[] = $total;
    }

    ob_start();
    if ( $current > 1 ) {
        $prev_url = add_query_arg('ext_page', $current - 1);
        echo '<a href="' . esc_url($prev_url) . '#extrato" data-ext-page="' . (int) ($current - 1) . '" class="rmbz-ext-page-btn" style="display:inline-block;padding:6px 12px;background:#252525;color:#fff;border:1px solid #444;border-radius:4px;text-decoration:none;font-size:12px;">← Anterior</a>';
    }

    foreach ( $pages_to_show as $p ) {
        if ( $p === '...' ) {
            echo '<span style="padding:6px 4px;color:#666;font-size:12px;">…</span>';
            continue;
        }
        $is_current = ((int) $p === (int) $current);
        $style = $is_current
            ? 'background:#f0b90b;color:#000;font-weight:bold;border-color:#f0b90b;'
            : 'background:#252525;color:#fff;border-color:#444;';
        $url = add_query_arg('ext_page', (int) $p);
        echo '<a href="' . esc_url($url) . '#extrato" data-ext-page="' . (int) $p . '" class="rmbz-ext-page-btn" style="display:inline-block;padding:6px 12px;border:1px solid;border-radius:4px;text-decoration:none;font-size:12px;min-width:32px;text-align:center;' . $style . '">' . (int) $p . '</a>';
    }

    if ( $current < $total ) {
        $next_url = add_query_arg('ext_page', $current + 1);
        echo '<a href="' . esc_url($next_url) . '#extrato" data-ext-page="' . (int) ($current + 1) . '" class="rmbz-ext-page-btn" style="display:inline-block;padding:6px 12px;background:#252525;color:#fff;border:1px solid #444;border-radius:4px;text-decoration:none;font-size:12px;">Próxima →</a>';
    }

    return ob_get_clean();
}