<?php
/**
 * 1MZ For Artists — API Middleware
 *
 * Funções partilhadas pelos controllers:
 *   - rmbz_api_authenticate_request()  → resolve o utilizador a partir do
 *     header Authorization. Devolve telemovel ou WP_Error.
 *   - rmbz_api_require_auth()          → permission_callback drop-in.
 *   - rmbz_api_rate_limit()            → janela deslizante por bucket.
 *   - rmbz_api_get_client_ip()         → IP real respeitando proxies.
 *
 * @package 1MZ_For_Artists
 * @since   1.6.0
 */

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

/**
 * Extrai o token Bearer do header Authorization.
 *
 * Aceita também o header HTTP_AUTHORIZATION (alguns servidores não passam
 * Authorization para o PHP por causa de configurações de Apache/CGI).
 *
 * @return string|null
 */
function rmbz_api_get_bearer_token() {
    $header = '';

    if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
        $header = $_SERVER['HTTP_AUTHORIZATION'];
    } elseif ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
        $header = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    } elseif ( function_exists( 'getallheaders' ) ) {
        $headers = getallheaders();
        if ( is_array( $headers ) ) {
            foreach ( $headers as $k => $v ) {
                if ( strcasecmp( $k, 'Authorization' ) === 0 ) {
                    $header = $v;
                    break;
                }
            }
        }
    }

    if ( ! $header ) {
        return null;
    }

    if ( ! preg_match( '/Bearer\s+(.+)/i', $header, $m ) ) {
        return null;
    }

    return trim( $m[1] );
}

/**
 * Autentica o request: valida o JWT e confirma que o utilizador existe.
 *
 * @return string|WP_Error Telemovel autenticado, ou erro.
 */
function rmbz_api_authenticate_request() {
    $token = rmbz_api_get_bearer_token();
    if ( ! $token ) {
        return new WP_Error( 'no_auth', 'Falta o cabeçalho Authorization.', [ 'status' => 401 ] );
    }

    $payload = rmbz_api_jwt_decode( $token );
    if ( is_wp_error( $payload ) ) {
        return $payload;
    }

    if ( empty( $payload['sub'] ) || empty( $payload['typ'] ) || $payload['typ'] !== 'access' ) {
        return new WP_Error( 'bad_token_type', 'Tipo de token inválido.', [ 'status' => 401 ] );
    }

    $telemovel = (string) $payload['sub'];

    // [1.5.215] Verificar se o admin (ou outro login) revogou tokens deste user.
    // is_revoked agora devolve a RAZÃO (string truthy) ou false.
    //   'admin'      → admin clicou Revogar
    //   'taken_over' → user fez login noutro device (1 sessão por conta)
    if ( function_exists( 'rmbz_app_token_is_revoked' ) ) {
        $iat = (int) ( $payload['iat'] ?? 0 );
        $reason = rmbz_app_token_is_revoked( $telemovel, $iat );
        if ( $reason ) {
            // Razão específica → app mostra Toast diferente
            if ( $reason === 'taken_over' ) {
                return new WP_Error(
                    'session_taken_over',
                    'Entraste com esta conta noutro dispositivo.',
                    [ 'status' => 401 ]
                );
            }
            return new WP_Error(
                'token_revoked',
                'A tua sessão foi terminada pelo administrador. Faz login novamente.',
                [ 'status' => 401 ]
            );
        }
    }

    // Confirmar que a conta ainda existe e não foi banida
    global $wpdb;
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, is_banned, extra_data FROM {$wpdb->prefix}rmbz_wallets WHERE user_telemovel = %s",
        $telemovel
    ) );

    if ( ! $row ) {
        return new WP_Error( 'account_missing', 'Conta não encontrada.', [ 'status' => 401 ] );
    }

    // [1.6.336] Aplica expirações de bloqueios/banimentos temporários (lazy):
    // se a data de desbloqueio já passou, são limpos aqui e o is_banned efetivo
    // é o já corrigido — reutiliza a linha lida (sem SELECT extra).
    $effective_banned = function_exists( 'rmbz_apply_block_expiries' )
        ? rmbz_apply_block_expiries( $telemovel, $row )
        : (int) $row->is_banned;

    if ( (int) $effective_banned === 1 ) {
        $ban_msg = function_exists( 'rmbz_banned_message' ) ? rmbz_banned_message( $telemovel ) : 'Conta suspensa.';
        return new WP_Error( 'account_banned', $ban_msg, [ 'status' => 403 ] );
    }

    return $telemovel;
}

/**
 * Permission callback drop-in para WP_REST_Server::register_route.
 *
 * Coloca o telemovel autenticado em $request->set_param('rmbz_telemovel', X)
 * para os handlers o usarem sem revalidar.
 *
 * @param WP_REST_Request $request
 * @return true|WP_Error
 */
function rmbz_api_require_auth( WP_REST_Request $request ) {
    $auth = rmbz_api_authenticate_request();
    if ( is_wp_error( $auth ) ) {
        return $auth;
    }

    // [1.5.212] Bloqueio de emergência: se admin marcou versões antigas
    // como inseguras, recusamos auth de versões abaixo da mínima.
    // O app deve mostrar o ecrã de force update.
    if ( function_exists( 'rmbz_app_version_should_block_auth' ) ) {
        $client_code = (int) ( $_SERVER['HTTP_X_1MZ_APP_VERSION_CODE'] ?? 0 );
        if ( rmbz_app_version_should_block_auth( $client_code ) ) {
            return new WP_Error(
                'app_version_blocked',
                'Esta versão da app foi bloqueada por motivos de segurança. ' .
                'Por favor atualiza para a versão mais recente.',
                [ 'status' => 426 ] // Upgrade Required
            );
        }
    }

    $request->set_param( 'rmbz_telemovel', $auth );

    // [1.5.213] Telemetria leve para o painel de versões.
    // Marca "user online" via transient (TTL 5 min) — barato, sem BD.
    // Actualiza app_version e last_used_at no token actual — uma vez
    // por hora máximo via rate limit interno para não escrever na BD
    // a cada request.
    if ( function_exists( 'rmbz_app_telemetry_record' ) ) {
        rmbz_app_telemetry_record( $auth );
    }

    // [1.6.118.14] Actualizar `last_seen` em extra_data da wallet para
    // o admin "Gestão de Artistas" detectar utilizadores activos via app.
    // Site usa heartbeat AJAX em my-account-page.php; app não tinha forma.
    // Rate limit: 1 update / 2 minutos por user (transient barato).
    rmbz_api_touch_last_seen( $auth );

    return true;
}


/**
 * [1.6.118.14] Actualiza `extra_data['last_seen']` da wallet para
 * sinalizar "online" no admin. Rate-limited via transient.
 *
 * @param string $telemovel
 */
function rmbz_api_touch_last_seen( $telemovel ) {
    if ( empty( $telemovel ) ) return;

    // Rate limit: só uma vez a cada 2 minutos por user.
    // Mais frequente que o ciclo de detecção online (5 min) para o
    // admin mostrar sempre o status correcto.
    $cache_key = 'rmbz_last_seen_' . md5( $telemovel );
    if ( get_transient( $cache_key ) ) {
        return;
    }
    set_transient( $cache_key, 1, 2 * MINUTE_IN_SECONDS );

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

    $wallet = $wpdb->get_row( $wpdb->prepare(
        "SELECT extra_data FROM {$table} WHERE user_telemovel = %s LIMIT 1",
        $telemovel
    ) );
    if ( ! $wallet ) return;

    $extra = ! empty( $wallet->extra_data ) ? json_decode( $wallet->extra_data, true ) : array();
    if ( ! is_array( $extra ) ) $extra = array();

    // [1.6.320] Deteção de ausência: se passou um intervalo grande (> 20 min)
    // desde a última presença, o artista esteve OFFLINE. Guardamos `away_since`
    // = momento em que ficou offline, para o assistente saber exatamente o que
    // surgiu enquanto esteve fora ("o que perdi offline?").
    $now_ts    = time();
    $prev_seen = isset( $extra['last_seen'] ) ? (int) $extra['last_seen'] : 0;
    if ( $prev_seen > 0 && ( $now_ts - $prev_seen ) > 20 * MINUTE_IN_SECONDS ) {
        $extra['away_since'] = $prev_seen;
    }
    $extra['last_seen'] = $now_ts;

    $wpdb->update(
        $table,
        array( 'extra_data' => wp_json_encode( $extra ) ),
        array( 'user_telemovel' => $telemovel ),
        array( '%s' ), array( '%s' )
    );
}

/**
 * IP do cliente respeitando proxies (Cloudflare, nginx, etc.).
 *
 * Usado para rate limit e auditoria. Se o teu site está atrás de um proxy
 * que NÃO defines como confiável, este valor pode ser falsificado pelo
 * cliente — por isso aceitamos só os headers dos proxies conhecidos.
 *
 * @return string
 */
function rmbz_api_get_client_ip() {
    $headers = apply_filters( 'rmbz_api_trusted_proxy_headers', [
        'HTTP_CF_CONNECTING_IP',   // Cloudflare
        'HTTP_X_REAL_IP',          // nginx
        'HTTP_X_FORWARDED_FOR',    // genérico
    ] );

    foreach ( $headers as $h ) {
        if ( ! empty( $_SERVER[ $h ] ) ) {
            // X-Forwarded-For pode ser uma lista — ficamos com o primeiro
            $ip = trim( explode( ',', $_SERVER[ $h ] )[0] );
            if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
                return $ip;
            }
        }
    }

    return isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : 'unknown';
}

/**
 * Rate limit por bucket.
 *
 * Janela deslizante simples baseada em DB. A cada hit incrementa o contador;
 * se exceder o máximo dentro da janela, devolve false.
 *
 * @param string $bucket_key  Chave única (ex: 'login_ip_1.2.3.4', 'wallet_user_+258...').
 * @param int    $max_hits    Hits permitidos na janela.
 * @param int    $window_secs Janela em segundos.
 * @return array { allowed: bool, remaining: int, reset_in: int }
 */
function rmbz_api_rate_limit( $bucket_key, $max_hits = 60, $window_secs = 60 ) {
    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_api_rate_limits';
    $now = current_time( 'mysql', 1 );
    $bucket_key = substr( $bucket_key, 0, 120 );

    // Tentar inserir nova janela; se já existe, incrementa.
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, hit_count, window_start FROM {$table} WHERE bucket_key = %s",
        $bucket_key
    ) );

    if ( ! $row ) {
        $wpdb->insert( $table, [
            'bucket_key'   => $bucket_key,
            'hit_count'    => 1,
            'window_start' => $now,
        ], [ '%s', '%d', '%s' ] );

        return [ 'allowed' => true, 'remaining' => $max_hits - 1, 'reset_in' => $window_secs ];
    }

    $window_age = time() - strtotime( $row->window_start . ' UTC' );

    // Janela expirou — reinicia
    if ( $window_age >= $window_secs ) {
        $wpdb->update( $table, [
            'hit_count'    => 1,
            'window_start' => $now,
        ], [ 'id' => $row->id ], [ '%d', '%s' ], [ '%d' ] );

        return [ 'allowed' => true, 'remaining' => $max_hits - 1, 'reset_in' => $window_secs ];
    }

    // Dentro da janela — incrementa
    $new_count = (int) $row->hit_count + 1;
    $wpdb->update( $table, [ 'hit_count' => $new_count ], [ 'id' => $row->id ], [ '%d' ], [ '%d' ] );

    $remaining = max( 0, $max_hits - $new_count );
    $reset_in = $window_secs - $window_age;

    return [
        'allowed'   => $new_count <= $max_hits,
        'remaining' => $remaining,
        'reset_in'  => $reset_in,
    ];
}

/**
 * Aplica headers de rate limit à resposta.
 *
 * @param array $rl Resultado de rmbz_api_rate_limit().
 */
function rmbz_api_apply_rate_limit_headers( array $rl ) {
    if ( ! headers_sent() ) {
        header( 'X-RateLimit-Remaining: ' . (int) $rl['remaining'] );
        header( 'X-RateLimit-Reset: ' . (int) $rl['reset_in'] );
    }
}

/* ----------------------------------------------------------------------- */
/* NO-CACHE — respostas autenticadas nunca devem ser cacheadas             */
/* ----------------------------------------------------------------------- */
/**
 * [1.5.279] Impede que CDNs/proxies (Cloudflare, cache de página do host,
 * etc.) guardem as respostas da API.
 *
 * Sintoma que isto resolve: o saldo da carteira no APP demorava a reflectir
 * um reembolso feito no admin, enquanto no SITE aparecia logo. Causa: o site
 * usa cookie de login (o cache faz bypass automático em sessões autenticadas)
 * mas o app autentica-se com "Authorization: Bearer" — uma camada de cache
 * que só ignora cookies acaba por servir a MESMA cópia em JSON ao token
 * durante todo o TTL, mesmo com pull-to-refresh. Ao marcar as respostas como
 * `private, no-store`, qualquer CDN/proxy bem comportado deixa de as guardar.
 *
 * Aplica-se a TODAS as rotas do namespace 1mz/v1 — são todas dados de
 * utilizador autenticado, nunca conteúdo público cacheável.
 */
add_filter( 'rest_post_dispatch', 'rmbz_api_no_cache_headers', 9, 3 );

function rmbz_api_no_cache_headers( $response, $server, $request ) {
    $route = is_object( $request ) && method_exists( $request, 'get_route' )
        ? (string) $request->get_route()
        : '';

    if ( strpos( $route, '/' . RMBZ_API_NAMESPACE . '/' ) !== 0 ) {
        return $response;
    }

    if ( $response instanceof WP_REST_Response ) {
        $response->header( 'Cache-Control', 'private, no-store, no-cache, must-revalidate, max-age=0' );
        $response->header( 'Pragma', 'no-cache' );
        $response->header( 'Expires', '0' );
        // Sinaliza explicitamente ao Cloudflare para não cachear.
        $response->header( 'CDN-Cache-Control', 'no-store' );
        $response->header( 'Cloudflare-CDN-Cache-Control', 'no-store' );
    }

    return $response;
}
