<?php
/**
 * 1MZ For Artists — Auth Controller
 *
 * Endpoints:
 *   POST /1mz/v1/auth/login                   — Login com telemovel + PIN (4 dígitos)
 *   POST /1mz/v1/auth/refresh                 — Trocar refresh token por novo access token
 *   POST /1mz/v1/auth/logout                  — Revogar o refresh token actual
 *   POST /1mz/v1/auth/logout_all              — Revogar TODOS os refresh tokens do utilizador
 *   GET  /1mz/v1/auth/me                      — Informação básica do utilizador autenticado
 *   GET  /1mz/v1/auth/devices                 — Lista de dispositivos com sessão activa
 *   POST /1mz/v1/auth/devices/revoke-others   — [1.6.27.3] Revogar tudo excepto a sessão actual
 *
 * Bugfix 1.6.27.3:
 *   - Refresh já não passa 'rotated' como device_label — propaga o label
 *     e user_agent originais. Antes, cada rotação criava entrada nova com
 *     label literal "rotated" que aparecia na tela "Sessões activas".
 *   - Endpoint /auth/devices/revoke-others permite ao user limpar com 1
 *     toque o histórico de tokens rotacionados acumulado.
 *
 * NOTA sobre TROCA DE PIN:
 *   O plugin web não suporta "trocar PIN com PIN actual" — a única forma
 *   de mudar o PIN é o fluxo de RECUPERAÇÃO (nome + telemóvel → email com
 *   chave de 6 dígitos → novo PIN), e está bloqueado 30 dias entre trocas.
 *   A API NÃO expõe esse fluxo — o utilizador faz no site.
 *
 * @package 1MZ_For_Artists
 * @since   1.6.0
 */

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

add_action( 'rest_api_init', 'rmbz_api_register_auth_routes' );

function rmbz_api_register_auth_routes() {
    register_rest_route( RMBZ_API_NAMESPACE, '/auth/login', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_login',
        'permission_callback' => '__return_true',
        'args' => [
            'telemovel'    => [ 'required' => true, 'type' => 'string' ],
            'pin'          => [ 'required' => true, 'type' => 'string' ],
            'device_label' => [ 'required' => false, 'type' => 'string' ],
            'device_id'    => [ 'required' => false, 'type' => 'string' ],
        ],
    ] );

    // [1.5.206] Registo de nova conta (com cupão OU depósito M-Pesa/E-Mola)
    register_rest_route( RMBZ_API_NAMESPACE, '/auth/register', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_register',
        'permission_callback' => '__return_true',
        'args' => [
            'nome'             => [ 'required' => true, 'type' => 'string' ],
            'telemovel'        => [ 'required' => true, 'type' => 'string' ],
            'pin'              => [ 'required' => true, 'type' => 'string' ],
            'email'            => [ 'required' => false, 'type' => 'string' ],
            'provincia'        => [ 'required' => false, 'type' => 'string' ],
            'cidade'           => [ 'required' => false, 'type' => 'string' ],
            // Tipo de activação: 'coupon' OU 'payment'
            'activation_type'  => [ 'required' => true, 'type' => 'string' ],
            // Se coupon
            'coupon_code'      => [ 'required' => false, 'type' => 'string' ],
            // Se payment
            'payment_method'   => [ 'required' => false, 'type' => 'string' ],
            'sender_phone'     => [ 'required' => false, 'type' => 'string' ],
            'transaction_id'   => [ 'required' => false, 'type' => 'string' ],
            // [1.5.207] Comprovativo (alternativa ao transaction_id) — base64
            'proof_image'      => [ 'required' => false, 'type' => 'string' ],
            // Convite opcional
            'referral_phone'   => [ 'required' => false, 'type' => 'string' ],
            // Auth tokens (login automático após criar)
            'device_label'     => [ 'required' => false, 'type' => 'string' ],
            'device_id'        => [ 'required' => false, 'type' => 'string' ],
        ],
    ] );

    // [1.5.207] Info pública para preencher o ecrã de registo:
    // valor a transferir + destinos M-Pesa/E-Mola. Sem auth — qualquer um
    // pode chamar antes de criar conta.
    register_rest_route( RMBZ_API_NAMESPACE, '/auth/register-info', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_register_info',
        'permission_callback' => '__return_true',
    ] );

    register_rest_route( RMBZ_API_NAMESPACE, '/auth/refresh', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_refresh',
        'permission_callback' => '__return_true',
        'args' => [
            'refresh_token' => [ 'required' => true, 'type' => 'string' ],
        ],
    ] );

    register_rest_route( RMBZ_API_NAMESPACE, '/auth/logout', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_logout',
        'permission_callback' => 'rmbz_api_require_auth',
        'args' => [
            'refresh_token' => [ 'required' => true, 'type' => 'string' ],
        ],
    ] );

    register_rest_route( RMBZ_API_NAMESPACE, '/auth/logout_all', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_logout_all',
        'permission_callback' => 'rmbz_api_require_auth',
    ] );

    register_rest_route( RMBZ_API_NAMESPACE, '/auth/me', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_me',
        'permission_callback' => 'rmbz_api_require_auth',
    ] );

    register_rest_route( RMBZ_API_NAMESPACE, '/auth/devices', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_devices',
        'permission_callback' => 'rmbz_api_require_auth',
    ] );

    // [1.6.27.3] Revogar todas as sessões EXCEPTO a actual.
    // Usado pela app para limpar lixo histórico de tokens rotacionados.
    // Revoke individual já existe em DELETE /auth/devices/{id}
    // (registado em auth-devices-extra.php).
    register_rest_route( RMBZ_API_NAMESPACE, '/auth/devices/revoke-others', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_devices_revoke_others',
        'permission_callback' => 'rmbz_api_require_auth',
    ] );
}

/* ----------------------------------------------------------------------- */
/* LOGIN                                                                   */
/* ----------------------------------------------------------------------- */

function rmbz_api_handle_login( WP_REST_Request $request ) {
    $ip = rmbz_api_get_client_ip();

    // [1.6.47] Bloqueio global de logins (manutenção total da plataforma)
    if ( function_exists( 'rmbz_is_globally_blocked' ) && rmbz_is_globally_blocked( 'logins' ) ) {
        return rmbz_api_error(
            'Plataforma em manutenção. Os logins estão temporariamente suspensos. Tenta de novo daqui a alguns minutos.',
            503,
            'system_maintenance'
        );
    }

    $rl_ip = rmbz_api_rate_limit( 'login_ip_' . md5( $ip ), 10, 300 );
    if ( ! $rl_ip['allowed'] ) {
        rmbz_api_apply_rate_limit_headers( $rl_ip );
        return rmbz_api_error( 'Demasiadas tentativas deste IP. Aguarda alguns minutos.', 429, 'rate_limited' );
    }

    $telemovel_raw = (string) $request->get_param( 'telemovel' );
    $pin           = (string) $request->get_param( 'pin' );
    $device_label  = (string) $request->get_param( 'device_label' );
    $device_id     = (string) $request->get_param( 'device_id' );

    if ( $telemovel_raw === '' || $pin === '' ) {
        return rmbz_api_error( 'Telemóvel e PIN são obrigatórios.', 400, 'missing_fields' );
    }

    // [REGRA PLUGIN] PIN deve ter EXATAMENTE 4 dígitos numéricos
    if ( ! preg_match( '/^\d{4}$/', $pin ) ) {
        return rmbz_api_error( 'O PIN deve ter exatamente 4 dígitos numéricos.', 400, 'bad_pin_format' );
    }

    $telemovel = rmbz_api_normalize_telemovel( $telemovel_raw );
    if ( ! rmbz_api_is_valid_telemovel( $telemovel ) ) {
        return rmbz_api_error( 'Formato de telemóvel inválido.', 400, 'bad_telemovel' );
    }

    $rl_user = rmbz_api_rate_limit( 'login_user_' . md5( $telemovel ), 5, 900 );
    if ( ! $rl_user['allowed'] ) {
        rmbz_api_apply_rate_limit_headers( $rl_user );
        return rmbz_api_error( 'Demasiadas tentativas para este número. Aguarda 15 minutos.', 429, 'rate_limited' );
    }

    if ( ! function_exists( 'rmbz_verify_user_pin' ) ) {
        return rmbz_api_error( 'Sistema indisponível.', 500, 'auth_unavailable' );
    }

    if ( ! rmbz_verify_user_pin( $telemovel, $pin ) ) {
        return rmbz_api_error( 'Telemóvel ou PIN incorrectos.', 401, 'bad_credentials' );
    }

    global $wpdb;
    // [1.6.336] Banimento temporário expira sozinho — aplica antes de barrar.
    $banned = function_exists( 'rmbz_apply_block_expiries' )
        ? (int) rmbz_apply_block_expiries( $telemovel )
        : (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT is_banned FROM {$wpdb->prefix}rmbz_wallets WHERE user_telemovel = %s",
            $telemovel
        ) );
    if ( $banned === 1 ) {
        $ban_msg = function_exists( 'rmbz_banned_message' ) ? rmbz_banned_message( $telemovel ) : 'Conta suspensa. Contacta o suporte.';
        return rmbz_api_error( $ban_msg, 403, 'account_banned' );
    }

    $tokens = rmbz_api_issue_tokens_for_user( $telemovel, [
        'device_label' => $device_label,
        'device_id'    => $device_id,
        'ip'           => $ip,
        'ua'           => isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( (string) $_SERVER['HTTP_USER_AGENT'], 0, 255 ) : null,
    ] );

    do_action( 'rmbz_user_logged_in', $telemovel );

    return rmbz_api_ok( [
        'access_token'  => $tokens['access_token'],
        'refresh_token' => $tokens['refresh_token'],
        'token_type'    => 'Bearer',
        'expires_in'    => RMBZ_API_ACCESS_TOKEN_TTL,
        'user'          => rmbz_api_format_wallet( $telemovel ),
    ] );
}

/* ----------------------------------------------------------------------- */
/* REFRESH                                                                 */
/* ----------------------------------------------------------------------- */

function rmbz_api_handle_refresh( WP_REST_Request $request ) {
    $refresh = (string) $request->get_param( 'refresh_token' );
    if ( $refresh === '' ) {
        return rmbz_api_error( 'Refresh token em falta.', 400, 'missing_refresh' );
    }

    global $wpdb;
    $hash = rmbz_api_hash_refresh_token( $refresh );

    // [1.6.27.3] Bugfix: trazer device_label e user_agent originais para
    // propagar à nova entrada após rotação. Antes guardava-se a string
    // literal "rotated" como label, fazendo a tela "Sessões activas"
    // mostrar 'rotated' em vez do nome do telefone.
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, user_telemovel, expires_at, revoked_at, revoke_reason, device_label, user_agent
         FROM {$wpdb->prefix}rmbz_api_refresh_tokens WHERE token_hash = %s",
        $hash
    ) );

    if ( ! $row ) {
        return rmbz_api_error( 'Refresh token inválido.', 401, 'bad_refresh' );
    }

    if ( $row->revoked_at ) {
        // [1.6.358] Tolerância à ROTAÇÃO: se o token foi rotacionado há poucos
        // segundos (corrida de pedidos paralelos / retry de rede), NÃO desligar a
        // sessão — emitir um novo par. Só vale para reason 'rotated'; revogações
        // por admin ou login noutro device continuam a barrar (logout correto).
        $grace = 60;
        $rotated_recent = ( $row->revoke_reason === 'rotated' )
            && ( time() - strtotime( $row->revoked_at . ' UTC' ) <= $grace );
        if ( ! $rotated_recent ) {
            return rmbz_api_error( 'Refresh token revogado.', 401, 'revoked' );
        }
        // Senão: cai através e emite novo par (tolerância de rotação).
    }

    if ( strtotime( $row->expires_at . ' UTC' ) < time() ) {
        return rmbz_api_error( 'Refresh token expirado.', 401, 'expired' );
    }

    $banned = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT is_banned FROM {$wpdb->prefix}rmbz_wallets WHERE user_telemovel = %s",
        $row->user_telemovel
    ) );
    if ( $banned === 1 ) {
        return rmbz_api_error( 'Conta suspensa.', 403, 'account_banned' );
    }

    $wpdb->update(
        $wpdb->prefix . 'rmbz_api_refresh_tokens',
        [ 'revoked_at' => current_time( 'mysql', 1 ), 'revoke_reason' => 'rotated' ],
        [ 'id' => $row->id ],
        [ '%s', '%s' ], [ '%d' ]
    );

    // [1.6.27.3] Propagar label e UA originais. Se vier UA actualizado no
    // header (caso raro mas possível em updates de versão da app) usa-se
    // o novo; senão herda do refresh antigo.
    $current_ua = isset( $_SERVER['HTTP_USER_AGENT'] )
        ? substr( (string) $_SERVER['HTTP_USER_AGENT'], 0, 255 )
        : null;

    $tokens = rmbz_api_issue_tokens_for_user( $row->user_telemovel, [
        'device_label' => $row->device_label, // ← antes era 'rotated' (BUG)
        'ip'           => rmbz_api_get_client_ip(),
        'ua'           => $current_ua ?: $row->user_agent,
        // [1.5.216] Refresh é rotação da MESMA sessão — não fazer kick.
        // Apenas LOGIN/REGISTER novos é que devem revogar outras sessões.
        'skip_kick'    => true,
    ] );

    return rmbz_api_ok( [
        'access_token'  => $tokens['access_token'],
        'refresh_token' => $tokens['refresh_token'],
        'token_type'    => 'Bearer',
        'expires_in'    => RMBZ_API_ACCESS_TOKEN_TTL,
    ] );
}

/* ----------------------------------------------------------------------- */
/* LOGOUT                                                                  */
/* ----------------------------------------------------------------------- */

function rmbz_api_handle_logout( WP_REST_Request $request ) {
    $telemovel = (string) $request->get_param( 'rmbz_telemovel' );
    $refresh   = (string) $request->get_param( 'refresh_token' );

    global $wpdb;
    $wpdb->update(
        $wpdb->prefix . 'rmbz_api_refresh_tokens',
        [ 'revoked_at' => current_time( 'mysql', 1 ), 'revoke_reason' => 'logout' ],
        [
            'token_hash'     => rmbz_api_hash_refresh_token( $refresh ),
            'user_telemovel' => $telemovel,
        ],
        [ '%s', '%s' ],
        [ '%s', '%s' ]
    );

    return rmbz_api_ok( [ 'logged_out' => true ] );
}

function rmbz_api_handle_logout_all( WP_REST_Request $request ) {
    $telemovel = (string) $request->get_param( 'rmbz_telemovel' );

    global $wpdb;
    $count = $wpdb->query( $wpdb->prepare(
        "UPDATE {$wpdb->prefix}rmbz_api_refresh_tokens
         SET revoked_at = %s, revoke_reason = 'logout_all'
         WHERE user_telemovel = %s AND revoked_at IS NULL",
        current_time( 'mysql', 1 ),
        $telemovel
    ) );

    return rmbz_api_ok( [ 'logged_out' => true, 'sessions_revoked' => (int) $count ] );
}

/* ----------------------------------------------------------------------- */
/* ME / DEVICES                                                            */
/* ----------------------------------------------------------------------- */

function rmbz_api_handle_me( WP_REST_Request $request ) {
    $telemovel = (string) $request->get_param( 'rmbz_telemovel' );
    $user = rmbz_api_format_wallet( $telemovel );
    if ( ! $user ) {
        return rmbz_api_error( 'Conta não encontrada.', 404, 'not_found' );
    }
    return rmbz_api_ok( $user );
}

function rmbz_api_handle_devices( WP_REST_Request $request ) {
    $telemovel = (string) $request->get_param( 'rmbz_telemovel' );

    global $wpdb;
    $rows = $wpdb->get_results( $wpdb->prepare(
        "SELECT id, device_label, ip_address, user_agent, created_at, last_used_at, expires_at
         FROM {$wpdb->prefix}rmbz_api_refresh_tokens
         WHERE user_telemovel = %s AND revoked_at IS NULL AND expires_at > NOW()
         ORDER BY last_used_at DESC, created_at DESC",
        $telemovel
    ) );

    $devices = array_map( function ( $r ) {
        return [
            'id'            => (int) $r->id,
            'label'         => $r->device_label,
            'ip'            => $r->ip_address,
            'user_agent'    => $r->user_agent,
            'created_at'    => mysql_to_rfc3339( $r->created_at ),
            'last_used_at'  => $r->last_used_at ? mysql_to_rfc3339( $r->last_used_at ) : null,
            'expires_at'    => mysql_to_rfc3339( $r->expires_at ),
        ];
    }, $rows ?: [] );

    return rmbz_api_ok( [ 'devices' => $devices ] );
}

/* ----------------------------------------------------------------------- */
/* [1.6.27.3] REVOKE-OTHERS                                                */
/* ----------------------------------------------------------------------- */

/**
 * POST /auth/devices/revoke-others
 *
 * Revoga todas as sessões activas EXCEPTO a indicada pelo refresh_token.
 * Permite ao user limpar com 1 toque o histórico de tokens rotacionados
 * acumulados ao longo do tempo (sintoma do bug "rotated" pré-1.6.27.3).
 *
 * Body JSON: { "refresh_token": "<o refresh token actual da app>" }
 *
 * Por segurança o servidor exige o refresh token (não basta o access).
 * Assim só o cliente que tem ambos consegue fazer esta operação.
 */
function rmbz_api_handle_devices_revoke_others( WP_REST_Request $request ) {
    $telemovel = (string) $request->get_param( 'rmbz_telemovel' );

    $body = $request->get_json_params();
    if ( ! is_array( $body ) ) $body = [];
    $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';

    if ( $refresh === '' ) {
        return rmbz_api_error(
            'É preciso enviar o refresh_token actual para preservar a sessão deste device.',
            400, 'missing_refresh'
        );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_api_refresh_tokens';
    $hash  = rmbz_api_hash_refresh_token( $refresh );

    // Validar que o refresh enviado pertence mesmo ao user autenticado
    $current = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, user_telemovel, revoked_at
         FROM $table WHERE token_hash = %s",
        $hash
    ) );

    if ( ! $current ) {
        return rmbz_api_error( 'Refresh token inválido.', 400, 'bad_refresh' );
    }
    if ( $current->user_telemovel !== $telemovel ) {
        return rmbz_api_error( 'Refresh token não corresponde ao utilizador.', 403, 'forbidden' );
    }
    if ( $current->revoked_at ) {
        return rmbz_api_error( 'Sessão actual já estava terminada — refresh primeiro.', 409, 'current_revoked' );
    }

    // Revogar TODAS as sessões deste user excepto esta
    $revoked = $wpdb->query( $wpdb->prepare(
        "UPDATE $table
         SET revoked_at = %s, revoke_reason = 'revoked_others'
         WHERE user_telemovel = %s
           AND revoked_at IS NULL
           AND id != %d",
        current_time( 'mysql', 1 ),
        $telemovel,
        $current->id
    ) );

    if ( $revoked === false ) {
        return rmbz_api_error( 'Falha a revogar sessões.', 500, 'db_error' );
    }

    return rmbz_api_ok( [
        'revoked_count' => (int) $revoked,
        'kept_id'       => (int) $current->id,
        'message'       => $revoked > 0
            ? "Foram terminadas $revoked outras sessões."
            : 'Não havia outras sessões para terminar.',
    ] );
}

/* ----------------------------------------------------------------------- */
/* HELPER PARTILHADO                                                       */
/* ----------------------------------------------------------------------- */

function rmbz_api_issue_tokens_for_user( $telemovel, array $meta = [] ) {
    global $wpdb;

    // [1.5.216] Single-session policy — 1 sessão por conta.
    // Antes de emitir tokens novos, revoga TODAS as sessões existentes
    // deste utilizador (em qualquer dispositivo). O outro dispositivo
    // cai com code="session_taken_over" e Toast "Entraste noutro dispositivo".
    //
    // Esta lógica corre em TODOS os pontos de emissão de token:
    //   - login normal
    //   - refresh (rotação de token)
    //   - register (logo após criar conta)
    //
    // Para evitar self-kick no REFRESH, o caller passa $meta['skip_kick'] = true
    // (o refresh não deve revogar a própria sessão activa).
    if ( empty( $meta['skip_kick'] ) ) {
        $wpdb->query( $wpdb->prepare(
            "UPDATE {$wpdb->prefix}rmbz_api_refresh_tokens
             SET revoked_at = %s, revoke_reason = 'session_taken_over'
             WHERE user_telemovel = %s AND revoked_at IS NULL",
            current_time( 'mysql', 1 ),
            $telemovel
        ) );

        // Marcar cutoff de access tokens — JWT em memória dos outros
        // devices será rejeitado na próxima chamada com code 'session_taken_over'.
        // (a função vem de admin/app-token-revoke.php carregado em 1.5.215)
        if ( function_exists( 'rmbz_app_token_mark_revoked' ) ) {
            rmbz_app_token_mark_revoked( $telemovel, 'taken_over' );
        }
    }

    $access  = rmbz_api_jwt_make_access_token( $telemovel );
    $refresh = rmbz_api_generate_refresh_token();

    $now = current_time( 'mysql', 1 );
    $expires = gmdate( 'Y-m-d H:i:s', time() + RMBZ_API_REFRESH_TOKEN_TTL );

    $wpdb->insert(
        $wpdb->prefix . 'rmbz_api_refresh_tokens',
        [
            'token_hash'         => rmbz_api_hash_refresh_token( $refresh ),
            'user_telemovel'     => $telemovel,
            'device_label'       => isset( $meta['device_label'] ) ? substr( (string) $meta['device_label'], 0, 120 ) : null,
            'device_fingerprint' => isset( $meta['device_id'] ) ? substr( (string) $meta['device_id'], 0, 64 ) : null,
            'ip_address'         => isset( $meta['ip'] ) ? substr( (string) $meta['ip'], 0, 45 ) : null,
            'user_agent'         => isset( $meta['ua'] ) ? substr( (string) $meta['ua'], 0, 255 ) : null,
            'created_at'         => $now,
            'last_used_at'       => $now,
            'expires_at'         => $expires,
        ],
        [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
    );

    return [
        'access_token'  => $access,
        'refresh_token' => $refresh,
    ];
}

/* ----------------------------------------------------------------------- */
/* REGISTER — [1.5.206] criar nova conta + login automático                */
/* ----------------------------------------------------------------------- */

/**
 * Cria uma nova conta de utilizador (wallet) + activação via cupão ou
 * via depósito M-Pesa/E-Mola. Réplica do `onemz_process_registration`
 * (web form) mas via REST API.
 *
 * Fluxo:
 *   1. Validar campos obrigatórios (nome, telemóvel, PIN, activation_type)
 *   2. Validar formato (PIN 4 dígitos, telemóvel 9 dígitos)
 *   3. Anti-flood: rate limit por IP
 *   4. Validar activação:
 *      - 'coupon' → verifica código existe + válido + não esgotado +
 *        reward_type=vip_days
 *      - 'payment' → verifica metodo, sender_phone, transaction_id
 *   5. Verificar se telemóvel já existe
 *   6. Criar wallet via `rmbz_create_user_account()` (PIN é hasheado lá)
 *   7. Guardar província/cidade em extra_data
 *   8. Se referral_phone preenchido, gravar via rmbz_referral_create_pending
 *   9. Activar:
 *      - 'coupon' → cria subscription + aplica vip_permissions + marca usage
 *      - 'payment' → cria deposit_request pendente + alerta admin
 *  10. Emitir tokens (login automático) — devolve igual ao /auth/login
 *
 * Responde com a mesma estrutura do /auth/login para que a app possa
 * salvar tokens e ir directamente ao Home.
 */
function rmbz_api_handle_register( WP_REST_Request $request ) {
    $ip = rmbz_api_get_client_ip();

    // [1.6.47] Bloqueio global de logins/registos
    if ( function_exists( 'rmbz_is_globally_blocked' ) && rmbz_is_globally_blocked( 'logins' ) ) {
        return rmbz_api_error(
            'Plataforma em manutenção. Os registos estão temporariamente suspensos. Tenta de novo daqui a alguns minutos.',
            503,
            'system_maintenance'
        );
    }

    // Rate limit anti-flood: 5 tentativas por IP em 15 minutos.
    $rl_ip = rmbz_api_rate_limit( 'register_ip_' . md5( $ip ), 5, 900 );
    if ( ! $rl_ip['allowed'] ) {
        rmbz_api_apply_rate_limit_headers( $rl_ip );
        return rmbz_api_error( 'Demasiadas tentativas deste IP. Aguarda 15 minutos.', 429, 'rate_limited' );
    }

    // ─── Validar campos obrigatórios ─────────────────────────
    $nome            = trim( (string) $request->get_param( 'nome' ) );
    // [1.6.118.9] Normalizar capitalização — "joão silva" → "João Silva"
    if ( function_exists( 'rmbz_normalize_artist_name' ) ) {
        $nome = rmbz_normalize_artist_name( $nome );
    }
    $telemovel_raw   = (string) $request->get_param( 'telemovel' );
    $pin             = (string) $request->get_param( 'pin' );
    $email           = trim( (string) $request->get_param( 'email' ) );
    $provincia       = trim( (string) $request->get_param( 'provincia' ) );
    $cidade          = trim( (string) $request->get_param( 'cidade' ) );
    $activation_type = strtolower( trim( (string) $request->get_param( 'activation_type' ) ) );
    $device_label    = (string) $request->get_param( 'device_label' );
    $device_id       = (string) $request->get_param( 'device_id' );

    if ( $nome === '' || $telemovel_raw === '' || $pin === '' ) {
        return rmbz_api_error( 'Nome, telemóvel e PIN são obrigatórios.', 400, 'missing_fields' );
    }
    if ( ! in_array( $activation_type, array( 'coupon', 'payment' ), true ) ) {
        return rmbz_api_error( 'Tipo de activação inválido (coupon ou payment).', 400, 'bad_activation_type' );
    }

    // ─── Validar formato telemóvel e PIN ─────────────────────
    if ( ! preg_match( '/^\d{4}$/', $pin ) ) {
        return rmbz_api_error( 'O PIN deve ter exatamente 4 dígitos numéricos.', 400, 'bad_pin_format' );
    }

    $telemovel = rmbz_api_normalize_telemovel( $telemovel_raw );
    if ( ! rmbz_api_is_valid_telemovel( $telemovel ) ) {
        return rmbz_api_error( 'Formato de telemóvel inválido. Deve ter 9 dígitos (ex: 841234567).', 400, 'bad_telemovel' );
    }

    if ( strlen( $nome ) < 2 ) {
        return rmbz_api_error( 'Nome deve ter pelo menos 2 caracteres.', 400, 'bad_nome' );
    }

    // [1.6.118.10] Validar contra lista de nomes reservados
    // ("Artista", "Teste", "Admin", etc).
    if ( function_exists( 'rmbz_validate_artist_name' ) ) {
        $valid = rmbz_validate_artist_name( $nome );
        if ( $valid !== true ) {
            return rmbz_api_error( $valid, 400, 'bad_nome_reserved' );
        }
    }

    if ( $email !== '' && ! is_email( $email ) ) {
        return rmbz_api_error( 'Email inválido.', 400, 'bad_email' );
    }

    global $wpdb;
    $table_wallets = $wpdb->prefix . 'rmbz_wallets';

    // ─── Verificar duplicado ─────────────────────────────────
    $exists = $wpdb->get_var( $wpdb->prepare(
        "SELECT id FROM {$table_wallets} WHERE user_telemovel = %s",
        $telemovel
    ) );
    if ( $exists ) {
        return rmbz_api_error(
            'Este número já está registado. Tenta fazer login.',
            409, 'already_registered'
        );
    }

    // ─── Validar activação ANTES de criar conta ──────────────
    $coupon = null;
    $payment_method = '';
    $sender_phone   = '';
    $transaction_id = '';

    if ( $activation_type === 'coupon' ) {
        $coupon_code = strtoupper( str_replace( ' ', '', (string) $request->get_param( 'coupon_code' ) ) );
        if ( $coupon_code === '' ) {
            return rmbz_api_error( 'Código do cupão é obrigatório.', 400, 'missing_coupon' );
        }

        $table_coupons = $wpdb->prefix . 'rmbz_coupons';
        $coupon = $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$table_coupons} WHERE coupon_code = %s",
            $coupon_code
        ) );
        if ( ! $coupon ) {
            return rmbz_api_error( 'Cupão não encontrado.', 404, 'coupon_not_found' );
        }
        if ( strtolower( (string) $coupon->status ) !== 'ativo' ) {
            return rmbz_api_error( 'Cupão inactivo.', 400, 'coupon_inactive' );
        }
        if ( ! empty( $coupon->expiry_date ) && strtotime( (string) $coupon->expiry_date ) < time() ) {
            return rmbz_api_error( 'Cupão expirado.', 400, 'coupon_expired' );
        }
        if ( intval( $coupon->usage_limit ) > 0 && intval( $coupon->used_count ) >= intval( $coupon->usage_limit ) ) {
            return rmbz_api_error( 'Cupão esgotado.', 400, 'coupon_exhausted' );
        }
        if ( (string) $coupon->reward_type !== 'vip_days' ) {
            return rmbz_api_error(
                'Apenas cupões VIP são válidos para criar conta.',
                400, 'coupon_not_vip_days'
            );
        }

    } elseif ( $activation_type === 'payment' ) {
        $payment_method     = sanitize_text_field( (string) $request->get_param( 'payment_method' ) );
        $sender_phone       = sanitize_text_field( (string) $request->get_param( 'sender_phone' ) );
        $transaction_id     = sanitize_text_field( (string) $request->get_param( 'transaction_id' ) );
        // [1.5.207] Imagem base64 (data URL ou puro base64) — alternativa ao transaction_id
        $proof_image_b64    = (string) $request->get_param( 'proof_image' );
        $proof_image_url    = ''; // URL final após upload

        if ( $payment_method === '' || ! in_array( strtolower( $payment_method ), array( 'mpesa', 'emola', 'm-pesa', 'e-mola' ), true ) ) {
            return rmbz_api_error( 'Método de pagamento inválido (mpesa ou emola).', 400, 'bad_payment_method' );
        }
        if ( $sender_phone === '' ) {
            return rmbz_api_error( 'Número que enviou o pagamento é obrigatório.', 400, 'missing_sender_phone' );
        }

        // [1.5.207] Aceita transaction_id OU proof_image — pelo menos um.
        $has_tx     = $transaction_id !== '';
        $has_proof  = $proof_image_b64 !== '';
        if ( ! $has_tx && ! $has_proof ) {
            return rmbz_api_error(
                'É necessário o ID da transacção OU uma imagem do comprovativo.',
                400, 'missing_proof'
            );
        }

        // Se proof_image foi enviado, decodificar e gravar no disco como ficheiro.
        // Aceita "data:image/png;base64,...." ou apenas o base64 puro.
        if ( $has_proof ) {
            $proof_image_url = rmbz_api_save_base64_proof( $proof_image_b64 );
            if ( ! $proof_image_url ) {
                return rmbz_api_error(
                    'Falha a processar a imagem do comprovativo. Tenta outra imagem.',
                    400, 'bad_proof_image'
                );
            }
        }
    }

    // ─── Criar conta ─────────────────────────────────────────
    if ( ! function_exists( 'rmbz_create_user_account' ) ) {
        return rmbz_api_error( 'Sistema indisponível para criar contas.', 500, 'registration_unavailable' );
    }
    $created = rmbz_create_user_account( $telemovel, $pin, $email, $nome );
    if ( ! $created ) {
        return rmbz_api_error( 'Falha ao gravar dados de utilizador.', 500, 'wallet_insert_failed' );
    }

    // [1.6.118.10] **BUG FIX**: província era guardada APENAS em extra_data,
    // mas o endpoint /profile lê `user_provincia` (coluna directa) →
    // app mostrava "(não definida)" mesmo quando user preencheu.
    // Agora gravamos em ambos os sítios:
    //   - user_provincia (coluna directa) — para o /profile
    //   - extra_data['provincia'] + ['cidade'] — para queries antigas
    if ( $provincia !== '' ) {
        $wpdb->update(
            $table_wallets,
            array( 'user_provincia' => $provincia ),
            array( 'user_telemovel' => $telemovel ),
            array( '%s' ), array( '%s' )
        );
    }

    // Guardar província/cidade em extra_data (para back-compat com queries antigas)
    if ( $provincia !== '' || $cidade !== '' ) {
        $wallet_row = $wpdb->get_row( $wpdb->prepare(
            "SELECT extra_data FROM {$table_wallets} WHERE user_telemovel = %s",
            $telemovel
        ) );
        $extra = ! empty( $wallet_row->extra_data ) ? json_decode( $wallet_row->extra_data, true ) : array();
        if ( ! is_array( $extra ) ) $extra = array();
        if ( $provincia !== '' ) $extra['provincia'] = $provincia;
        if ( $cidade !== ''    ) $extra['cidade']    = $cidade;
        $wpdb->update(
            $table_wallets,
            array( 'extra_data' => wp_json_encode( $extra ) ),
            array( 'user_telemovel' => $telemovel ),
            array( '%s' ), array( '%s' )
        );
    }

    // ─── Convite (referral) opcional ────────────────────────
    $referral_raw = trim( (string) $request->get_param( 'referral_phone' ) );
    if ( $referral_raw !== '' && function_exists( 'rmbz_referral_create_pending' ) ) {
        $clean = preg_replace( '/\s+/', '', $referral_raw );
        if ( strpos( $clean, '+258' ) !== 0 ) {
            $clean = '+258' . ltrim( $clean, '+' );
        }
        $ok = rmbz_referral_create_pending( $telemovel, $clean );
        if ( $ok ) {
            $wpdb->update(
                $table_wallets,
                array( 'referred_by' => $clean ),
                array( 'user_telemovel' => $telemovel ),
                array( '%s' ), array( '%s' )
            );
        }
    }

    // ─── Activação ──────────────────────────────────────────
    if ( $activation_type === 'coupon' && $coupon ) {
        $table_subs = $wpdb->prefix . 'rmbz_subscriptions';
        $days       = intval( $coupon->reward_value );
        $now        = current_time( 'mysql' );
        $now_ts     = current_time( 'timestamp' );
        $end_date   = date( 'Y-m-d H:i:s', strtotime( "+{$days} days", $now_ts ) );

        $wpdb->insert( $table_subs, array(
            'user_telemovel' => $telemovel,
            'start_date'     => $now,
            'end_date'       => $end_date,
            'status'         => 'active',
        ), array( '%s', '%s', '%s', '%s' ) );

        $extra_coupon = ! empty( $coupon->extra_data ) ? json_decode( $coupon->extra_data, true ) : array();
        if ( isset( $extra_coupon['vip_permissions'] ) ) {
            $wallet_row = $wpdb->get_row( $wpdb->prepare(
                "SELECT extra_data FROM {$table_wallets} WHERE user_telemovel = %s",
                $telemovel
            ) );
            $w_extra = ! empty( $wallet_row->extra_data ) ? json_decode( $wallet_row->extra_data, true ) : array();
            if ( ! is_array( $w_extra ) ) $w_extra = array();
            $w_extra['vip_permissions'] = $extra_coupon['vip_permissions'];
            $wpdb->update(
                $table_wallets,
                array( 'extra_data' => wp_json_encode( $w_extra ) ),
                array( 'user_telemovel' => $telemovel ),
                array( '%s' ), array( '%s' )
            );
        }

        $table_usage = $wpdb->prefix . 'rmbz_coupon_usage';
        $wpdb->insert( $table_usage, array(
            'coupon_id'      => $coupon->id,
            'user_telemovel' => $telemovel,
        ) );
        $wpdb->query( $wpdb->prepare(
            "UPDATE {$wpdb->prefix}rmbz_coupons SET used_count = used_count + 1 WHERE id = %d",
            $coupon->id
        ) );

    } elseif ( $activation_type === 'payment' ) {
        $first_deposit = (float) get_option( 'rmbz_first_deposit_min', 0 );
        if ( $first_deposit <= 0 ) {
            $first_deposit = (float) get_option( 'rmbz_sub_price', 1500 );
        }
        $table_deposits = $wpdb->prefix . 'rmbz_deposit_requests';

        $wpdb->insert( $table_deposits, array(
            'user_telemovel'   => $telemovel,
            'amount'           => $first_deposit,
            'status'           => 'pendente',
            'payment_method'   => $payment_method,
            'transaction_id'   => $transaction_id,
            'proof_image'      => $proof_image_url,
            'sender_phone'     => $sender_phone,
            'request_date'     => current_time( 'mysql', 1 ),
            // [1.6.118] Origem app + flag first deposit. Este endpoint é
            // só chamado no fluxo de registo, então is_first_deposit=1.
            'source_platform'  => 'app',
            'is_first_deposit' => 1,
        ), array( '%s', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' ) );
        $deposit_id = $wpdb->insert_id;

        // [1.6.118] Marcar user como "enforced 24h auto-delete" — só users
        // registados depois desta release ficam sujeitos a regra de
        // auto-delete. Users antigos não têm este meta → seguros.
        $new_user = get_user_by( 'login', $telemovel );
        if ( $new_user ) {
            update_user_meta( $new_user->ID, 'rmbz_first_deposit_required', 1 );
            update_user_meta( $new_user->ID, 'rmbz_first_deposit_at', current_time( 'mysql', 1 ) );
        }

        if ( function_exists( 'rmbz_send_admin_deposit_alert' ) ) {
            $tx_display = ! empty( $transaction_id )
                ? $transaction_id
                : ( $proof_image_url !== '' ? 'Imagem/Screenshot (Ver no painel)' : 'Sem ID' );
            rmbz_send_admin_deposit_alert(
                $nome, $first_deposit, $payment_method, $tx_display, $deposit_id
            );
        }

        if ( function_exists( 'rmbz_notify' ) ) {
            rmbz_notify( $telemovel, 'welcome_pending', array(
                'nome_artista' => $nome,
                'valor'        => number_format( $first_deposit, 0, ',', '.' ),
                'metodo'       => $payment_method,
            ) );
        }
    }

    // ─── Emitir tokens (login automático) ───────────────────
    $tokens = rmbz_api_issue_tokens_for_user( $telemovel, array(
        'device_label' => $device_label,
        'device_id'    => $device_id,
        'ip'           => $ip,
        'ua'           => isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( (string) $_SERVER['HTTP_USER_AGENT'], 0, 255 ) : null,
    ) );

    do_action( 'rmbz_user_registered', $telemovel, $activation_type );

    return rmbz_api_ok( array(
        'access_token'    => $tokens['access_token'],
        'refresh_token'   => $tokens['refresh_token'],
        'token_type'      => 'Bearer',
        'expires_in'      => RMBZ_API_ACCESS_TOKEN_TTL,
        'user'            => rmbz_api_format_wallet( $telemovel ),
        'activation_type' => $activation_type,
        'message'         => $activation_type === 'coupon'
            ? 'Conta criada! VIP activo imediatamente.'
            : 'Conta criada! O pagamento está em análise (até 24h).',
    ) );
}

/* ============================================================================
 * [1.5.207] GET /auth/register-info
 *
 * Endpoint público (sem auth) que devolve toda a info necessária para o
 * ecrã de registo poder mostrar:
 *   - valor a transferir (primeiro depósito mínimo OU preço sub padrão)
 *   - destinos M-Pesa / E-Mola (números + beneficiário)
 *   - moeda
 *
 * Permite que a app mostre instruções de pagamento dinamicamente em vez
 * de hard-coded. Se o admin mudar o número ou o valor no painel, a app
 * vê a mudança na próxima chamada.
 * ============================================================================ */
function rmbz_api_handle_register_info( WP_REST_Request $request ) {
    // ─── Valor ───────────────────────────────────────────────
    $valor = (float) get_option( 'rmbz_first_deposit_min', 0 );
    if ( $valor <= 0 ) {
        $valor = (float) get_option( 'rmbz_sub_price', 1500 );
    }
    $currency = (string) get_option( 'rmbz_currency_code', 'MZN' );

    // ─── Destinos ────────────────────────────────────────────
    $payment_settings = get_option( 'rmbz_payment_settings', array() );
    if ( ! is_array( $payment_settings ) ) $payment_settings = array();

    $mpesa = isset( $payment_settings['rmbz_mpesa_number'] )
        ? (string) $payment_settings['rmbz_mpesa_number'] : '';
    $emola = isset( $payment_settings['rmbz_emola_number'] )
        ? (string) $payment_settings['rmbz_emola_number'] : '';

    // [1.6.118.5] Nomes titulares por método (M-Pesa e E-Mola podem
    // estar em nomes diferentes — comum em MZ). Settings existentes:
    // rmbz_payment_settings['rmbz_mpesa_name'] e ['rmbz_emola_name'].
    // Fallback para o beneficiary global se setting específica vazia.
    $beneficiary = (string) get_option( 'rmbz_payment_beneficiary', '1MZ Records' );

    $mpesa_name = isset( $payment_settings['rmbz_mpesa_name'] ) && trim( (string) $payment_settings['rmbz_mpesa_name'] ) !== ''
        ? (string) $payment_settings['rmbz_mpesa_name']
        : $beneficiary;

    $emola_name = isset( $payment_settings['rmbz_emola_name'] ) && trim( (string) $payment_settings['rmbz_emola_name'] ) !== ''
        ? (string) $payment_settings['rmbz_emola_name']
        : $beneficiary;

    return rmbz_api_ok( array(
        'amount'      => $valor,
        'currency'    => $currency,
        // [1.6.118.5] Mantemos `beneficiary` no response por
        // back-compat com app v1.0.5 e anteriores. Apps novas (v1.0.6+)
        // usam o `name` de cada destination em vez deste valor global.
        'beneficiary' => $beneficiary,
        'destinations' => array(
            array(
                'method'  => 'mpesa',
                'label'   => 'M-Pesa',
                'number'  => $mpesa,
                'name'    => $mpesa_name,
                'enabled' => $mpesa !== '',
            ),
            array(
                'method'  => 'emola',
                'label'   => 'E-Mola',
                'number'  => $emola,
                'name'    => $emola_name,
                'enabled' => $emola !== '',
            ),
        ),
        'instructions' => 'Faça a transferência para o número indicado e depois '
            . 'submeta o ID da transação OU uma imagem do comprovativo.',
    ) );
}

/* ============================================================================
 * [1.5.207] Helper: gravar imagem base64 como ficheiro no uploads
 *
 * Aceita data URL ("data:image/png;base64,...") ou base64 puro.
 * Detecta o mime type pelo header da data URL (default: jpg).
 * Limite de 5 MB após decode.
 *
 * Retorna URL pública do ficheiro guardado, ou false em caso de falha.
 *
 * Ficheiro guardado em: /wp-content/uploads/1mz-proofs/proof_TIMESTAMP_RAND.ext
 * Como o user ainda não está autenticado quando faz registo, não podemos
 * usar a sua user_id no path. Usamos o telemóvel? Não — pode ser
 * sensível. Random é suficiente — ninguém vê o ficheiro além do admin.
 * ============================================================================ */
function rmbz_api_save_base64_proof( $b64 ) {
    if ( ! is_string( $b64 ) || $b64 === '' ) return false;

    // Detectar e remover prefixo data URL se existir
    $ext = 'jpg';
    $mime = 'image/jpeg';
    if ( preg_match( '/^data:image\/(png|jpe?g|webp|heic);base64,/i', $b64, $m ) ) {
        $found_ext = strtolower( $m[1] );
        $ext = ( $found_ext === 'jpeg' ) ? 'jpg' : $found_ext;
        $mime = 'image/' . ( $found_ext === 'jpg' ? 'jpeg' : $found_ext );
        $b64 = preg_replace( '/^data:image\/[^;]+;base64,/i', '', $b64 );
    }
    // Strip whitespace (alguns clientes adicionam newlines)
    $b64 = preg_replace( '/\s+/', '', $b64 );

    // Decode com strict mode
    $bin = base64_decode( $b64, true );
    if ( $bin === false || strlen( $bin ) === 0 ) {
        return false;
    }
    // Limite 5 MB (5 * 1024 * 1024)
    if ( strlen( $bin ) > 5242880 ) {
        return false;
    }

    // Garantir directoria de upload
    $upload_dir = wp_upload_dir();
    $target_dir = trailingslashit( $upload_dir['basedir'] ) . '1mz-proofs';
    if ( ! file_exists( $target_dir ) ) {
        if ( ! wp_mkdir_p( $target_dir ) ) return false;
        // .htaccess para impedir directory listing
        @file_put_contents( $target_dir . '/.htaccess', "Options -Indexes\n" );
    }

    $filename = sprintf(
        'proof_%s_%s.%s',
        date( 'YmdHis' ),
        wp_generate_password( 8, false, false ),
        $ext
    );
    $filepath = $target_dir . '/' . $filename;

    // Gravar
    if ( file_put_contents( $filepath, $bin ) === false ) {
        return false;
    }

    // URL pública
    return trailingslashit( $upload_dir['baseurl'] ) . '1mz-proofs/' . $filename;
}