<?php
/**
 * 1MZ Music Player — Programa de Músicas Patrocinadas ("ouvir e ganhar").
 *
 * O admin adiciona faixas patrocinadas ao programa de parceria, define os
 * PAÍSES elegíveis e a taxa em dólar por minuto ouvido. O app toca essas
 * faixas, conta os minutos e reporta ao servidor, que valida (país + orçamento)
 * e credita a conta do ouvinte. O saldo aparece no perfil, dentro do app.
 *
 * Tabelas:
 *   mp_sponsored_tracks   — as faixas do programa
 *   mp_sponsored_earnings — livro-razão de ganhos (por conta/faixa/dia)
 *   mp_sponsored_payouts  — pedidos e registos de pagamento
 *
 * @package 1MZ_Music_Player
 */

if ( ! defined( 'ABSPATH' ) ) exit;

define( 'MP_SPONSORED_DB', '1.4.0' );

/** Cria/atualiza as tabelas do programa. */
function mp_sponsored_install() {
    if ( get_option( 'mp_sponsored_db' ) === MP_SPONSORED_DB ) {
        return;
    }
    global $wpdb;
    $charset = $wpdb->get_charset_collate();

    $tracks = $wpdb->prefix . 'mp_sponsored_tracks';
    $sql1 = "CREATE TABLE {$tracks} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        title VARCHAR(200) NOT NULL,
        artist VARCHAR(200) NULL,
        audio_url TEXT NOT NULL,
        cover_url TEXT NULL,
        countries VARCHAR(600) NULL,
        rate_per_min DECIMAL(10,5) NOT NULL DEFAULT 0,
        budget_usd DECIMAL(12,2) NOT NULL DEFAULT 0,
        spent_usd DECIMAL(12,5) NOT NULL DEFAULT 0,
        duration_sec INT NOT NULL DEFAULT 0,
        plays BIGINT UNSIGNED NOT NULL DEFAULT 0,
        active TINYINT(1) NOT NULL DEFAULT 1,
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY active (active)
    ) {$charset};";

    $earn = $wpdb->prefix . 'mp_sponsored_earnings';
    $sql2 = "CREATE TABLE {$earn} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        account_id BIGINT UNSIGNED NOT NULL,
        track_id BIGINT UNSIGNED NOT NULL,
        seconds INT NOT NULL DEFAULT 0,
        earned_usd DECIMAL(12,5) NOT NULL DEFAULT 0,
        country VARCHAR(4) NULL,
        day DATE NOT NULL,
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY account_id (account_id),
        KEY day (day),
        KEY acc_track_day (account_id, track_id, day)
    ) {$charset};";

    $pay = $wpdb->prefix . 'mp_sponsored_payouts';
    $sql3 = "CREATE TABLE {$pay} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        account_id BIGINT UNSIGNED NOT NULL,
        amount_usd DECIMAL(12,2) NOT NULL,
        method VARCHAR(60) NULL,
        detail VARCHAR(200) NULL,
        status VARCHAR(20) NOT NULL DEFAULT 'requested',
        note VARCHAR(255) NULL,
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        paid_at DATETIME NULL,
        PRIMARY KEY (id),
        KEY account_id (account_id),
        KEY status (status)
    ) {$charset};";

    // Sessões de reprodução — o coração do anti-fraude por heartbeats.
    $sess = $wpdb->prefix . 'mp_sponsored_sessions';
    $sql4 = "CREATE TABLE {$sess} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        token VARCHAR(64) NOT NULL,
        account_id BIGINT UNSIGNED NOT NULL,
        track_id BIGINT UNSIGNED NOT NULL,
        country VARCHAR(4) NULL,
        started_at INT NOT NULL,
        last_beat INT NOT NULL,
        beat_seq INT UNSIGNED NOT NULL DEFAULT 0,
        position_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
        credited_sec INT NOT NULL DEFAULT 0,
        credited_usd DECIMAL(12,5) NOT NULL DEFAULT 0,
        play_counted TINYINT(1) NOT NULL DEFAULT 0,
        good_beats INT NOT NULL DEFAULT 0,
        bad_beats INT NOT NULL DEFAULT 0,
        vol_last INT NOT NULL DEFAULT 100,
        vol_sum BIGINT NOT NULL DEFAULT 0,
        vol_n INT NOT NULL DEFAULT 0,
        vol_min INT NOT NULL DEFAULT 100,
        low_vol_beats INT NOT NULL DEFAULT 0,
        route VARCHAR(8) NOT NULL DEFAULT 'phone',
        untrusted_beats INT NOT NULL DEFAULT 0,
        status VARCHAR(16) NOT NULL DEFAULT 'open',
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        UNIQUE KEY token (token),
        KEY account_id (account_id)
    ) {$charset};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql1 );
    dbDelta( $sql2 );
    dbDelta( $sql3 );
    dbDelta( $sql4 );

    // Migração 1.4: versões antigas somavam um "play" em cada heartbeat.
    // Recalcula para uma reprodução por sessão que realmente recebeu crédito.
    $wpdb->query( "UPDATE {$sess} SET play_counted = 1 WHERE credited_sec > 0" );
    $wpdb->query( "UPDATE {$tracks} t SET plays = (SELECT COUNT(*) FROM {$sess} s WHERE s.track_id = t.id AND s.credited_sec > 0)" );
    update_option( 'mp_sponsored_db', MP_SPONSORED_DB );
}
add_action( 'init', 'mp_sponsored_install' );

add_action( 'rest_api_init', 'mp_register_sponsored_routes' );
function mp_register_sponsored_routes() {
    register_rest_route( MP_API_NAMESPACE, '/sponsored/tracks', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_sponsored_tracks',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/report', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_sponsored_report',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/earnings', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_sponsored_earnings',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/payout', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_sponsored_payout',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/accept', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_sponsored_accept',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/session/start', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_session_start',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/session/beat', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_session_beat',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/session/stop', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_session_stop',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/verify-check', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_verify_check',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/sponsored/verify-submit', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_verify_submit',
        'permission_callback' => '__return_true',
    ] );
}

/**
 * GET /sponsored/verify-check — o app pergunta se o admin pediu uma verificação
 * ao dispositivo. Quando pending=true, o telemóvel faz o auto-diagnóstico e
 * envia por /verify-submit.
 */
function mp_handle_verify_check( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    $v = (string) get_option( 'mp_sponsored_verify_' . (int) $row->id, '' );
    return rmbz_api_ok( [ 'pending' => ( $v === 'requested' ) ] );
}

/**
 * POST /sponsored/verify-submit {device, integrity} — o dispositivo entrega a
 * prova (modelo, se é emulador/root, pontuação de integridade local) + o resumo
 * de escuta. A IA analisa e o veredicto volta ao admin (guardado no pedido).
 */
function mp_handle_verify_submit( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    $aid = (int) $row->id;
    if ( get_option( 'mp_sponsored_verify_' . $aid, '' ) !== 'requested' ) {
        return rmbz_api_ok( [ 'done' => true ] ); // nada pendente
    }
    $device    = sanitize_textarea_field( (string) $request->get_param( 'device' ) );
    $integrity = max( 0, min( 100, (int) $request->get_param( 'integrity' ) ) );

    $verdict = 'IA indisponível — o dispositivo respondeu (integridade ' . $integrity . '/100).';
    if ( function_exists( 'mp_ai_call' ) && mp_ai_enabled() ) {
        global $wpdb;
        $earn = $wpdb->prefix . 'mp_sponsored_earnings';
        $secs = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(seconds),0) FROM {$earn} WHERE account_id = %d", $aid ) );
        $usd  = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(earned_usd),0) FROM {$earn} WHERE account_id = %d", $aid ) );
        $report = "Auto-diagnóstico do dispositivo do utilizador:\n{$device}\n"
                . "Pontuação de integridade local: {$integrity}/100\n"
                . "Escuta patrocinada: " . round( $secs / 60 ) . " min · ganho \${$usd}";
        $system = 'És o verificador de fraude do LADO DO DISPOSITIVO de uma app de música "ouvir e ganhar". '
                . 'Recebes o auto-diagnóstico do telemóvel do utilizador (modelo, se é emulador ou tem root, '
                . 'pontuação de integridade) e o resumo de escuta. Sinais de fraude: emulador, root, integridade baixa, '
                . 'ganho alto face à escuta. Responde em português, no máximo 2 frases, começando por LEGÍTIMO, SUSPEITO ou FRAUDE.';
        $out = mp_ai_call( $system, $report, 220 );
        if ( ! is_wp_error( $out ) ) {
            $verdict = trim( (string) $out );
        }
    }

    // Guarda o veredicto do dispositivo (visível ao admin) e fecha o pedido.
    update_option( 'mp_sponsored_verify_' . $aid, '📱 ' . $verdict, false );
    // Anexa ao pedido de pagamento pendente, se existir.
    global $wpdb;
    $pay = $wpdb->prefix . 'mp_sponsored_payouts';
    $wpdb->query( $wpdb->prepare(
        "UPDATE {$pay} SET note = %s WHERE account_id = %d AND status = 'requested' ORDER BY id DESC LIMIT 1",
        mb_substr( '📱 ' . $verdict, 0, 255 ), $aid
    ) );
    return rmbz_api_ok( [ 'done' => true ] );
}

/** Saldo disponível ao ouvinte, já descontando ganhos devolvidos (clawback). */
function mp_sponsored_clawback( $account_id ) {
    return (float) get_option( 'mp_sponsored_clawback_' . (int) $account_id, 0 );
}

/** Intervalo entre heartbeats (segundos). O cliente bate a cada [BEAT]. */
const MP_BEAT_SEC = 15;

/**
 * POST /sponsored/session/start {track_id, country, position_ms} — abre uma sessão de escuta
 * validada. Fecha qualquer sessão aberta anterior (1 por conta = não dá para
 * "ouvir" várias faixas ao mesmo tempo). Exige internet: sem esta chamada, não
 * há reprodução contabilizável.
 */
function mp_handle_session_start( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_sponsored_enabled() ) {
        return rmbz_api_error( 'Programa indisponível.', 403, 'disabled' );
    }
    $aid      = (int) $row->id;
    $track_id = (int) $request->get_param( 'track_id' );
    $country  = mp_country_norm( $request->get_param( 'country' ) );
    $position = max( 0, (int) $request->get_param( 'position_ms' ) );

    if ( ! mp_sponsored_accepted( $aid ) ) {
        return rmbz_api_error( 'Aceita o contrato primeiro.', 403, 'not_accepted' );
    }
    if ( ! mp_sponsored_country_ok( $country ) ) {
        return rmbz_api_error( 'Programa indisponível no teu país.', 403, 'geo_global' );
    }
    global $wpdb;
    $tbl   = $wpdb->prefix . 'mp_sponsored_tracks';
    $track = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tbl} WHERE id = %d AND active = 1", $track_id ) );
    if ( ! $track ) {
        return rmbz_api_error( 'Faixa não faz parte do programa.', 404, 'not_found' );
    }
    if ( ! mp_track_eligible( $track->countries, $country ) ) {
        return rmbz_api_error( 'Não disponível no teu país.', 403, 'geo' );
    }
    if ( $track->budget_usd > 0 && $track->spent_usd >= $track->budget_usd ) {
        return rmbz_api_error( 'Orçamento desta faixa esgotado.', 403, 'budget' );
    }
    if ( (int) $track->duration_sec > 0 && $position > ( (int) $track->duration_sec * 1000 ) ) {
        return rmbz_api_error( 'Posição de reprodução inválida.', 400, 'invalid_position' );
    }

    $sess = $wpdb->prefix . 'mp_sponsored_sessions';
    // Fecha sessões abertas do utilizador (só uma escuta de cada vez).
    $wpdb->query( $wpdb->prepare( "UPDATE {$sess} SET status = 'closed' WHERE account_id = %d AND status = 'open'", $aid ) );

    $token = bin2hex( random_bytes( 16 ) );
    $now   = time();
    $wpdb->insert( $sess, [
        'token'      => $token,
        'account_id' => $aid,
        'track_id'   => $track_id,
        'country'    => $country,
        'started_at' => $now,
        'last_beat'  => $now,
        'beat_seq'   => 0,
        'position_ms' => $position,
        'status'     => 'open',
    ] );
    return rmbz_api_ok( [
        'session'      => $token,
        'beat_sec'     => MP_BEAT_SEC,
        'rate_per_min' => (float) $track->rate_per_min,
    ] );
}

/**
 * POST /sponsored/session/beat {session} — o batimento do coração. O crédito
 * SÓ nasce aqui, validado contra o RELÓGIO DE PAREDE do servidor:
 *   · o intervalo real entre batidas tem de ser ~MP_BEAT_SEC (não dá para
 *     acelerar o tempo nem enviar batidas em rajada);
 *   · o crédito por sessão nunca passa a DURAÇÃO da faixa (nada de loops);
 *   · respeita o teto diário por faixa;
 *   · batidas más (fora do ritmo) sobem o bad_beats → sessão morre e conta é
 *     marcada para revisão anti-fraude.
 */
function mp_handle_session_beat( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    global $wpdb;
    $sess  = $wpdb->prefix . 'mp_sponsored_sessions';
    $token = sanitize_text_field( (string) $request->get_param( 'session' ) );
    $s = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$sess} WHERE token = %s AND account_id = %d AND status = 'open'", $token, (int) $row->id ) );
    if ( ! $s ) {
        return rmbz_api_error( 'Sessão inválida — recomeça a reprodução.', 409, 'no_session' );
    }

    $sequence_param = $request->get_param( 'sequence' );
    $position_param = $request->get_param( 'position_ms' );
    if ( $sequence_param === null || $position_param === null ) {
        $wpdb->update( $sess, [ 'status' => 'closed' ], [ 'id' => $s->id ] );
        return rmbz_api_error( 'Atualiza o app para validar esta reprodução.', 426, 'client_upgrade' );
    }

    $sequence = max( 0, (int) $sequence_param );
    $position = max( 0, (int) $position_param );
    $last_seq = (int) $s->beat_seq;
    if ( $sequence <= $last_seq ) {
        return rmbz_api_ok( [
            'credited_usd' => 0,
            'balance_usd'  => mp_sponsored_balance( (int) $row->id ),
            'ignored'      => true,
            'duplicate'    => true,
            'sequence'     => $last_seq,
        ] );
    }
    if ( $sequence !== $last_seq + 1 ) {
        $wpdb->update( $sess, [ 'status' => 'closed', 'bad_beats' => (int) $s->bad_beats + 1 ], [ 'id' => $s->id ] );
        return rmbz_api_error( 'Sequência de reprodução inválida.', 409, 'sequence_gap' );
    }

    $now      = time();
    $interval = $now - (int) $s->last_beat;
    $tbl      = $wpdb->prefix . 'mp_sponsored_tracks';
    $track    = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tbl} WHERE id = %d", (int) $s->track_id ) );
    if ( ! $track ) {
        $wpdb->update( $sess, [ 'status' => 'closed' ], [ 'id' => $s->id ] );
        return rmbz_api_error( 'Faixa removida.', 404, 'gone' );
    }

    $too_fast = $interval < ( MP_BEAT_SEC * 0.5 );
    if ( $too_fast ) {
        $bad  = (int) $s->bad_beats + 1;
        $kill = $bad >= 3;
        $wpdb->update( $sess, [
            'bad_beats' => $bad,
            'status'    => $kill ? 'fraud' : 'open',
        ], [ 'id' => $s->id ] );
        if ( $kill ) {
            mp_sponsored_flag( (int) $row->id, 'batidas rápidas a mais numa sessão' );
            return rmbz_api_error( 'Reprodução inválida detetada.', 429, 'fraud' );
        }
        return rmbz_api_ok( [
            'credited_usd' => 0,
            'ignored'      => true,
            'balance_usd'  => mp_sponsored_balance( (int) $row->id ),
            'sequence'     => $last_seq,
        ] );
    }

    $position_delta = $position - (int) $s->position_ms;
    $max_progress   = max( MP_BEAT_SEC + 5, $interval + 5 ) * 1000;
    $duration_ms    = max( 0, (int) $track->duration_sec * 1000 );
    $position_bad   = $position_delta <= 0 || $position_delta > $max_progress ||
        ( $duration_ms > 0 && $position > $duration_ms + 2000 );
    if ( $position_bad ) {
        $wpdb->update( $sess, [
            'status'    => 'closed',
            'bad_beats' => (int) $s->bad_beats + 1,
        ], [ 'id' => $s->id ] );
        return rmbz_api_error( 'Saltos e recuos não contam nesta faixa.', 409, 'playback_mismatch' );
    }

    // ── REGRA DE VOLUME (server-side, inviolável) ───────────────────────────
    // O cliente é OBRIGADO a mandar o volume real (0–100) em cada batida. O
    // servidor é que decide: abaixo do mínimo → 0 crédito e pausa server-side
    // por N minutos (mesmo que o volume volte a subir). Fica tudo registado na
    // sessão para a IA anti-fraude ver o padrão. Sem volume no pedido → tratado
    // como 0 (nunca dá para ganhar sem reportar o volume).
    $vol       = (int) $request->get_param( 'volume' ); // 0–100
    $vol       = max( 0, min( 100, $vol ) );
    $has_vol   = ( $request->get_param( 'volume' ) !== null );
    $min_vol   = (int) get_option( 'mp_sponsored_min_volume', 20 );
    $pause_min = max( 1, (int) get_option( 'mp_sponsored_vol_pause_min', 10 ) );
    $vpause_k  = 'mp_sponsored_vpause_' . (int) $row->id;

    // ── ROTA DE SAÍDA (carro / Bluetooth) ───────────────────────────────────
    // Ligado ao CARRO ou a Bluetooth, o volume do telemóvel NÃO controla o
    // volume real (o carro pode estar em mute e o telefone mostrar 100%). Nessas
    // rotas o volume não é fiável: a regra de volume não se aplica e entra a
    // política definida no admin (permitir / sinalizar / bloquear).
    $route       = sanitize_key( (string) $request->get_param( 'route' ) ); // phone|wired|bt|car
    if ( ! in_array( $route, [ 'phone', 'wired', 'bt', 'car' ], true ) ) { $route = 'phone'; }
    $vol_trusted = ( $route === 'phone' || $route === 'wired' );
    $car_mode    = get_option( 'mp_sponsored_car_earn', 'flag' ); // allow|flag|block

    // Volume demasiado baixo (ou em falta) — SÓ conta em rotas fiáveis.
    $low_now   = ( $vol_trusted && $min_vol > 0 && ( ! $has_vol || $vol < $min_vol ) );
    // Ainda dentro de uma pausa server-side de volume baixo anterior?
    $was_paused = ( $vol_trusted && (int) get_option( $vpause_k, 0 ) > $now );

    // Atualiza as estatísticas de volume + rota da sessão (a IA lê isto).
    $vol_upd = [
        'vol_last' => $vol,
        'vol_sum'  => (int) $s->vol_sum + $vol,
        'vol_n'    => (int) $s->vol_n + 1,
        'vol_min'  => min( (int) $s->vol_min, $vol ),
        'route'    => $route,
        'beat_seq' => $sequence,
        'position_ms' => $position,
    ];

    // Rota não fiável (carro/Bluetooth) → aplica a política do admin.
    if ( ! $vol_trusted ) {
        $ub = (int) $s->untrusted_beats + 1;
        $vol_upd['untrusted_beats'] = $ub;
        if ( $car_mode === 'block' ) {
            $vol_upd['last_beat'] = $now; // mantém o ritmo, mas sem crédito
            $wpdb->update( $sess, $vol_upd, [ 'id' => $s->id ] );
            return rmbz_api_ok( [
                'credited_usd' => 0,
                'balance_usd'  => mp_sponsored_balance( (int) $row->id ),
                'blocked_route' => true,
                'fresh'         => ( $ub === 1 ), // 1.º batimento nesta rota → mostra o modal uma vez
                'route'         => $route,
                'sequence'      => $sequence,
            ] );
        }
        // 'flag' → conta, mas sinaliza escuta prolongada em rota não verificável.
        if ( $car_mode === 'flag' && $ub === 12 ) {
            mp_sponsored_flag( (int) $row->id, 'escuta prolongada em carro/Bluetooth (volume não verificável)' );
        }
        // 'allow'/'flag': segue em frente e credita normalmente (sem regra de volume).
    }

    if ( $low_now || $was_paused ) {
        // A pausa dura X minutos FIXOS. Só é iniciada quando um NOVO episódio de
        // volume baixo começa (não se renova a cada batida) — assim o aviso pisca
        // uma única vez por episódio e a pausa acaba mesmo. `fresh` = este é o
        // batimento que iniciou a pausa (o único em que o app mostra o modal).
        $fresh = ( $low_now && ! $was_paused );
        if ( $fresh ) {
            update_option( $vpause_k, $now + $pause_min * 60, false );
        }
        $low_beats = (int) $s->low_vol_beats + 1;
        $vol_upd['low_vol_beats'] = $low_beats;
        $vol_upd['last_beat']     = $now; // mantém o ritmo válido, mas sem crédito
        // Muitas batidas com volume baixo numa só sessão = padrão suspeito.
        if ( $low_beats === 8 ) {
            mp_sponsored_flag( (int) $row->id, 'muitas batidas com volume abaixo do mínimo' );
        }
        $wpdb->update( $sess, $vol_upd, [ 'id' => $s->id ] );
        $retry_at = (int) get_option( $vpause_k, 0 );
        return rmbz_api_ok( [
            'credited_usd'  => 0,
            'balance_usd'   => mp_sponsored_balance( (int) $row->id ),
            'low_volume'    => true,
            'fresh'         => $fresh, // só true no arranque do episódio
            'min_volume'    => $min_vol,
            'pause_min'     => $pause_min,
            'retry_in_sec'  => max( 0, $retry_at - $now ),
            'sequence'      => $sequence,
        ] );
    }

    // O crédito usa o menor valor entre relógio do servidor e avanço real no áudio.
    $credit_sec = min( $interval, MP_BEAT_SEC, (int) floor( $position_delta / 1000 ) );

    // Anti-fraude 2: por SESSÃO nunca passa a duração da faixa (+ pequena folga).
    $dur = (int) $track->duration_sec;
    if ( $dur > 0 ) {
        $room_sess  = max( 0, $dur - (int) $s->credited_sec );
        $credit_sec = min( $credit_sec, $room_sess );
    }

    // Anti-fraude 3: teto DIÁRIO por conta+faixa.
    $earn    = $wpdb->prefix . 'mp_sponsored_earnings';
    $today   = current_time( 'Y-m-d' );
    $cap_min = (int) get_option( 'mp_sponsored_daily_cap_min', 30 );
    $already = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(seconds),0) FROM {$earn} WHERE account_id = %d AND track_id = %d AND day = %s",
        (int) $row->id, (int) $s->track_id, $today ) );
    $credit_sec = min( $credit_sec, max( 0, $cap_min * 60 - $already ) );

    // Anti-fraude 4: ORÇAMENTO da faixa.
    $earned = round( ( $credit_sec / 60.0 ) * (float) $track->rate_per_min, 5 );
    if ( $track->budget_usd > 0 ) {
        $left   = max( 0, (float) $track->budget_usd - (float) $track->spent_usd );
        $earned = min( $earned, round( $left, 5 ) );
    }

    $count_play = $earned > 0 && $credit_sec > 0 && ! (int) $s->play_counted;

    // Regista o batimento (mesmo com crédito 0, para o ritmo continuar válido).
    $wpdb->update( $sess, array_merge( $vol_upd, [
        'last_beat'    => $now,
        'credited_sec' => (int) $s->credited_sec + $credit_sec,
        'credited_usd' => (float) $s->credited_usd + $earned,
        'good_beats'   => (int) $s->good_beats + 1,
        'play_counted' => $count_play ? 1 : (int) $s->play_counted,
    ] ), [ 'id' => $s->id ] );

    if ( $earned > 0 && $credit_sec > 0 ) {
        $wpdb->insert( $earn, [
            'account_id' => (int) $row->id,
            'track_id'   => (int) $s->track_id,
            'seconds'    => $credit_sec,
            'earned_usd' => $earned,
            'country'    => $s->country,
            'day'        => $today,
        ] );
        $wpdb->query( $wpdb->prepare(
            "UPDATE {$tbl} SET spent_usd = spent_usd + %f, plays = plays + %d WHERE id = %d",
            $earned, $count_play ? 1 : 0, (int) $s->track_id ) );
    }

    return rmbz_api_ok( [
        'credited_usd' => $earned,
        'balance_usd'  => mp_sponsored_balance( (int) $row->id ),
        'sequence'     => $sequence,
        // Faixa já rendeu o máximo nesta sessão → o cliente passa à próxima.
        'session_done' => ( $dur > 0 && (int) $s->credited_sec + $credit_sec >= $dur ),
    ] );
}

/** POST /sponsored/session/stop {session} — encerra a sessão (fim/pausa longa). */
function mp_handle_session_stop( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    global $wpdb;
    $sess  = $wpdb->prefix . 'mp_sponsored_sessions';
    $token = sanitize_text_field( (string) $request->get_param( 'session' ) );
    $wpdb->query( $wpdb->prepare(
        "UPDATE {$sess} SET status = 'closed' WHERE token = %s AND account_id = %d AND status = 'open'",
        $token, (int) $row->id ) );
    return rmbz_api_ok( [ 'closed' => true ] );
}

/**
 * IA ANTI-FRAUDE: resume o comportamento da conta e pede à Assistente um
 * veredicto (legítimo / suspeito / fraude) com uma justificação curta. Usa o
 * mesmo proxy Claude do resto do plugin — a chave nunca sai do servidor.
 */
function mp_sponsored_ai_fraud_review( $account_id ) {
    if ( ! function_exists( 'mp_ai_call' ) || ! mp_ai_enabled() ) {
        return 'IA indisponível — ativa a Assistente IA para a análise automática.';
    }
    global $wpdb;
    $sess = $wpdb->prefix . 'mp_sponsored_sessions';
    $earn = $wpdb->prefix . 'mp_sponsored_earnings';
    $aid  = (int) $account_id;

    $sessions = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$sess} WHERE account_id = %d", $aid ) );
    $good     = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(good_beats),0) FROM {$sess} WHERE account_id = %d", $aid ) );
    $bad      = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(bad_beats),0) FROM {$sess} WHERE account_id = %d", $aid ) );
    $fraud_s  = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$sess} WHERE account_id = %d AND status = 'fraud'", $aid ) );
    $days     = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT day) FROM {$earn} WHERE account_id = %d", $aid ) );
    $secs     = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(seconds),0) FROM {$earn} WHERE account_id = %d", $aid ) );
    $earned   = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(earned_usd),0) FROM {$earn} WHERE account_id = %d", $aid ) );
    $flags    = get_option( 'mp_sponsored_flags_' . $aid, [] );
    $flag_txt = is_array( $flags ) ? implode( '; ', array_map( function ( $f ) { return $f['r'] ?? ''; }, array_slice( $flags, -10 ) ) ) : '';

    // Comportamento de VOLUME (o servidor exige e regista o volume em cada batida).
    $vol_row  = $wpdb->get_row( $wpdb->prepare(
        "SELECT COALESCE(SUM(vol_sum),0) AS vsum, COALESCE(SUM(vol_n),0) AS vn,
                COALESCE(MIN(NULLIF(vol_min,101)),0) AS vmin, COALESCE(SUM(low_vol_beats),0) AS low,
                COALESCE(SUM(untrusted_beats),0) AS untrusted
         FROM {$sess} WHERE account_id = %d", $aid ) );
    $vol_n    = (int) ( $vol_row->vn ?? 0 );
    $vol_avg  = $vol_n > 0 ? round( (int) $vol_row->vsum / $vol_n ) : 0;
    $vol_min  = (int) ( $vol_row->vmin ?? 0 );
    $vol_low  = (int) ( $vol_row->low ?? 0 );
    $vol_car  = (int) ( $vol_row->untrusted ?? 0 );
    $min_vol  = (int) get_option( 'mp_sponsored_min_volume', 20 );
    $vol_txt  = $vol_n > 0
        ? "média {$vol_avg}%, mínimo {$vol_min}%, batidas abaixo do mínimo ({$min_vol}%): {$vol_low}"
        : 'sem dados de volume reportados';
    // Escuta em carro/Bluetooth: o volume do telemóvel não é verificável nessas rotas.
    $route_txt = $vol_n > 0
        ? ( $vol_car > 0
            ? "batidas em carro/Bluetooth (volume não verificável): {$vol_car} de {$vol_n} (" . round( 100 * $vol_car / max( 1, $vol_n ) ) . '%)'
            : 'sempre em rota fiável (telefone/fio)' )
        : 'sem dados de rota';

    $report = "Sessões: {$sessions} · batidas boas: {$good} · batidas más: {$bad} · sessões marcadas fraude: {$fraud_s}\n"
            . "Dias ativos: {$days} · minutos totais: " . round( $secs / 60 ) . " · ganho: \${$earned}\n"
            . "Volume durante a escuta: {$vol_txt}\n"
            . "Rota de saída: {$route_txt}\n"
            . "Bandeiras: " . ( $flag_txt ?: 'nenhuma' );

    $system = 'És o analista anti-fraude de um programa "ouvir e ganhar" de uma app de música. '
            . 'Recebes o resumo de comportamento de uma conta. Uma conta legítima ouve música em ritmo humano e com volume audível; '
            . 'sinais de fraude: muitas "batidas más" (tempo acelerado), muitas sessões marcadas fraude, ganho alto com poucos dias/minutos, '
            . 'minutos impossivelmente altos, volume sempre no mínimo/baixo ou constante artificial, '
            . 'ou quase toda a escuta em carro/Bluetooth (onde o volume real não é verificável e o carro pode estar em mute a fingir escuta). '
            . 'Responde em português, no máximo 2 frases: começa por LEGÍTIMO, SUSPEITO ou FRAUDE e explica porquê.';
    $out = mp_ai_call( $system, $report, 220 );
    if ( is_wp_error( $out ) ) {
        return 'Erro da IA: ' . $out->get_error_message();
    }
    return trim( (string) $out );
}

/** Marca a conta para revisão anti-fraude (score sobe; admin/IA revê). */
function mp_sponsored_flag( $account_id, $reason ) {
    $key   = 'mp_sponsored_flags_' . (int) $account_id;
    $flags = get_option( $key, [] );
    if ( ! is_array( $flags ) ) {
        $flags = [];
    }
    $flags[] = [ 't' => time(), 'r' => (string) $reason ];
    update_option( $key, array_slice( $flags, -50 ), false );
    // Score simples: nº de flags recentes.
    update_option( 'mp_sponsored_score_' . (int) $account_id, count( $flags ), false );
}

/** O ouvinte já aceitou o contrato (+18 e regras anti-fraude)? */
function mp_sponsored_accepted( $account_id ) {
    return get_option( 'mp_sponsored_accept_' . (int) $account_id, '' ) !== '';
}

/** POST /sponsored/accept — regista a aceitação do contrato (+18 anos). */
function mp_handle_sponsored_accept( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! (bool) $request->get_param( 'adult' ) ) {
        return rmbz_api_error( 'Tens de confirmar que tens 18 anos ou mais.', 400, 'not_adult' );
    }
    update_option( 'mp_sponsored_accept_' . (int) $row->id, current_time( 'mysql', true ), false );
    return rmbz_api_ok( [ 'accepted' => true ] );
}

/** O programa está ligado no admin? (também respeita o add-on 'sponsored'.) */
function mp_sponsored_enabled() {
    return (bool) get_option( 'mp_sponsored_on', 0 );
}

/**
 * Filtro GLOBAL de país do add-on (definido no modal Configurar). Vazio = todos
 * os países. É a primeira barreira: sem passar aqui, o programa nem aparece.
 */
function mp_sponsored_country_ok( $country ) {
    $csv  = (string) get_option( 'mp_sponsored_countries', '' );
    $list = array_filter( array_map( 'mp_country_norm', preg_split( '/[,\s]+/', $csv ) ) );
    if ( empty( $list ) ) {
        return true;
    }
    return in_array( mp_country_norm( $country ), $list, true );
}

/** Normaliza um código de país ISO-2 (ex.: "MZ"). */
function mp_country_norm( $c ) {
    return strtoupper( substr( preg_replace( '/[^A-Za-z]/', '', (string) $c ), 0, 2 ) );
}

/** A faixa é elegível neste país? Lista vazia = mundo inteiro. */
function mp_track_eligible( $countries_csv, $country ) {
    $list = array_filter( array_map( 'mp_country_norm', explode( ',', (string) $countries_csv ) ) );
    if ( empty( $list ) ) {
        return true; // sem restrição = todos os países
    }
    return in_array( mp_country_norm( $country ), $list, true );
}

/**
 * GET /sponsored/tracks?country=MZ — faixas ATIVAS e elegíveis no país do
 * ouvinte, com a taxa por minuto. O app mistura-as na reprodução.
 */
function mp_handle_sponsored_tracks( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_sponsored_enabled() ) {
        return rmbz_api_error( 'Programa indisponível.', 403, 'disabled' );
    }
    $country = mp_country_norm( $request->get_param( 'country' ) );
    if ( ! mp_sponsored_country_ok( $country ) ) {
        // País fora do programa → lista vazia (o cartão nem faz sentido mostrar).
        return rmbz_api_ok( [ 'tracks' => [], 'country' => $country, 'eligible' => false ] );
    }
    global $wpdb;
    $tbl  = $wpdb->prefix . 'mp_sponsored_tracks';
    $rows = $wpdb->get_results( "SELECT * FROM {$tbl} WHERE active = 1 ORDER BY id DESC LIMIT 200" );
    $out  = [];
    foreach ( $rows as $t ) {
        // Orçamento esgotado → sai do programa.
        if ( $t->budget_usd > 0 && $t->spent_usd >= $t->budget_usd ) {
            continue;
        }
        if ( ! mp_track_eligible( $t->countries, $country ) ) {
            continue;
        }
        $out[] = [
            'id'           => (int) $t->id,
            'title'        => $t->title,
            'artist'       => (string) $t->artist,
            'audio_url'    => $t->audio_url,
            'cover_url'    => (string) $t->cover_url,
            'rate_per_min' => (float) $t->rate_per_min,
            'duration_sec' => (int) $t->duration_sec,
        ];
    }
    return rmbz_api_ok( [
        'tracks'  => $out,
        'country' => $country,
        'min_payout' => (float) get_option( 'mp_sponsored_min_payout', 5 ),
    ] );
}

/** Endpoint legado: nunca credita; clientes atuais usam sessões validadas. */
function mp_handle_sponsored_report( WP_REST_Request $request ) {
    return rmbz_api_error(
        'Este método deixou de creditar reproduções. Atualiza o app para usar sessões validadas.',
        410,
        'session_required'
    );
}

/** Saldo disponível = ganho total − pago total. */
function mp_sponsored_balance( $account_id ) {
    global $wpdb;
    $earn = $wpdb->prefix . 'mp_sponsored_earnings';
    $pay  = $wpdb->prefix . 'mp_sponsored_payouts';
    $total = (float) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(earned_usd),0) FROM {$earn} WHERE account_id = %d", $account_id ) );
    $paid  = (float) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(amount_usd),0) FROM {$pay} WHERE account_id = %d AND status IN ('requested','paid')", $account_id ) );
    return round( $total - $paid - mp_sponsored_clawback( $account_id ), 2 );
}

/** GET /sponsored/earnings — resumo para o painel do perfil. */
function mp_handle_sponsored_earnings( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    global $wpdb;
    $earn = $wpdb->prefix . 'mp_sponsored_earnings';
    $pay  = $wpdb->prefix . 'mp_sponsored_payouts';
    $aid  = (int) $row->id;

    $total_earned = (float) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(earned_usd),0) FROM {$earn} WHERE account_id = %d", $aid ) );
    $total_seconds = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(seconds),0) FROM {$earn} WHERE account_id = %d", $aid ) );
    $today_earned = (float) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(earned_usd),0) FROM {$earn} WHERE account_id = %d AND day = %s",
        $aid, current_time( 'Y-m-d' ) ) );

    // Ganhos POR MÚSICA (com que faixas ganhaste, quanto e quantos minutos).
    $tbl_tr  = $wpdb->prefix . 'mp_sponsored_tracks';
    $by_track = $wpdb->get_results( $wpdb->prepare(
        "SELECT e.track_id, COALESCE(t.title,'(removida)') AS title, t.artist,
                SUM(e.earned_usd) AS earned, SUM(e.seconds) AS seconds
         FROM {$earn} e LEFT JOIN {$tbl_tr} t ON t.id = e.track_id
         WHERE e.account_id = %d
         GROUP BY e.track_id ORDER BY earned DESC LIMIT 100", $aid ) );

    $payouts = $wpdb->get_results( $wpdb->prepare(
        "SELECT amount_usd, status, method, created_at, paid_at FROM {$pay} WHERE account_id = %d ORDER BY id DESC LIMIT 20", $aid ) );

    return rmbz_api_ok( [
        'balance_usd'    => mp_sponsored_balance( $aid ),
        'total_earned'   => round( $total_earned, 2 ),
        'total_minutes'  => (int) floor( $total_seconds / 60 ),
        'today_earned'   => round( $today_earned, 4 ),
        'min_payout'     => (float) get_option( 'mp_sponsored_min_payout', 5 ),
        // Regra de volume definida no admin (o app aplica-a).
        'min_volume'     => (int) get_option( 'mp_sponsored_min_volume', 20 ),
        'vol_pause_min'  => (int) get_option( 'mp_sponsored_vol_pause_min', 10 ),
        'program_on'     => mp_sponsored_enabled(),
        'accepted'       => mp_sponsored_accepted( $aid ),
        'pay_methods'    => array_values( (array) get_option( 'mp_sponsored_methods', [ 'M-Pesa', 'Emola' ] ) ),
        'by_track'       => array_map( function ( $t ) {
            return [
                'title'   => (string) $t->title,
                'artist'  => (string) $t->artist,
                'earned'  => round( (float) $t->earned, 4 ),
                'minutes' => (int) floor( (int) $t->seconds / 60 ),
            ];
        }, $by_track ),
        'payouts'        => array_map( function ( $p ) {
            return [
                'amount' => (float) $p->amount_usd,
                'status' => $p->status,
                'method' => (string) $p->method,
                'date'   => $p->created_at,
                'paid'   => $p->paid_at,
            ];
        }, $payouts ),
    ] );
}

/** POST /sponsored/payout {method, detail} — o ouvinte pede o pagamento do saldo. */
function mp_handle_sponsored_payout( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    global $wpdb;
    $aid     = (int) $row->id;
    $balance = mp_sponsored_balance( $aid );
    $min     = (float) get_option( 'mp_sponsored_min_payout', 5 );
    if ( $balance < $min ) {
        return rmbz_api_error( "Saldo mínimo para pagamento: \${$min}.", 400, 'below_min' );
    }
    $method = sanitize_text_field( (string) $request->get_param( 'method' ) );
    $detail = sanitize_text_field( (string) $request->get_param( 'detail' ) );
    if ( $method === '' || $detail === '' ) {
        return rmbz_api_error( 'Indica o método e os dados de pagamento.', 400, 'bad_request' );
    }
    // Só métodos que o admin marcou como disponíveis.
    $available = (array) get_option( 'mp_sponsored_methods', [ 'M-Pesa', 'Emola' ] );
    if ( ! in_array( $method, $available, true ) ) {
        return rmbz_api_error( 'Esse método de pagamento não está disponível.', 400, 'bad_method' );
    }
    // Já há um pedido pendente?
    $pay = $wpdb->prefix . 'mp_sponsored_payouts';
    $pending = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$pay} WHERE account_id = %d AND status = 'requested'", $aid ) );
    if ( $pending > 0 ) {
        return rmbz_api_error( 'Já tens um pedido de pagamento em análise.', 409, 'pending' );
    }
    // A IA anti-fraude analisa AGORA o padrão de escuta desta conta — o veredicto
    // fica guardado no pedido para o admin decidir com segurança antes de pagar.
    $verdict = function_exists( 'mp_sponsored_ai_fraud_review' ) ? mp_sponsored_ai_fraud_review( $aid ) : '';
    $wpdb->insert( $pay, [
        'account_id' => $aid,
        'amount_usd' => $balance,
        'method'     => $method,
        'detail'     => $detail,
        'status'     => 'requested',
        'note'       => mb_substr( (string) $verdict, 0, 255 ),
    ] );
    return rmbz_api_ok( [ 'requested' => true, 'amount' => $balance ] );
}
