<?php
/**
 * 1MZ Music Player — Armazenamento na cloud (Amazon S3 / compatível)
 *
 * A chave AWS NUNCA vai no app. O plugin guarda as credenciais e gera URLs
 * PRÉ-ASSINADAS (SigV4) temporárias; o app faz upload/download DIRETO ao S3.
 *
 *   POST /1mzmusic/v1/s3/upload-url    {kind, filename, content_type} → {url, key}
 *   POST /1mzmusic/v1/s3/download-url  {key} → {url, key}
 *   POST /1mzmusic/v1/s3/delete        {key} → {deleted}
 *
 * kind: music (áudio do artista) | artist (perfil/capas) | backup (utilizador do player)
 *
 * @package 1MZ_Music_Player
 */

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

add_action( 'rest_api_init', 'mp_register_s3_routes' );

function mp_register_s3_routes() {
    register_rest_route( MP_API_NAMESPACE, '/s3/upload-url', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_s3_upload_url',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/download-url', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_s3_download_url',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/delete', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_s3_delete',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/key', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_s3_key',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/my-files', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_s3_my_files',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/web-catalog', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_s3_web_catalog',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/object-urls', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_s3_object_urls',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/quota', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_s3_quota',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/backup/list', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_backup_list',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/report-unreadable', [
        'methods'             => 'POST',
        'callback'            => 'mp_handle_report_unreadable',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/reupload-list', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_reupload_list',
        'permission_callback' => '__return_true',
    ] );
    register_rest_route( MP_API_NAMESPACE, '/s3/version', [
        'methods'             => 'GET',
        'callback'            => 'mp_handle_s3_version',
        'permission_callback' => '__return_true',
    ] );
}

/**
 * Versão da nuvem por conta: sobe a cada envio/remoção. O leitor web sonda
 * este número (leitura barata da BD, zero chamadas ao S3) e recarrega a lista
 * automaticamente quando muda — sem refresh manual.
 */
function mp_cloud_bump( $account_id ) {
    $opt = 'mp_cloudver_' . (int) $account_id;
    update_option( $opt, (int) get_option( $opt, 0 ) + 1, 'no' );
}

/** GET /s3/version — número atual da nuvem da conta. */
function mp_handle_s3_version( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    return rmbz_api_ok( [ 'v' => (int) get_option( 'mp_cloudver_' . (int) $row->id, 0 ) ] );
}

/**
 * Circuito de AUTO-CURA da nuvem: quando o painel/web deteta um ficheiro que
 * não desencripta (chave antiga), fica marcado para reenvio. O app pergunta
 * por esta lista e reenvia sozinho com a chave atual — o utilizador não perde
 * a música (o original continua no telemóvel).
 */
function mp_reup_add( $account_id, $key ) {
    $opt  = 'mp_reup_' . (int) $account_id;
    $list = get_option( $opt, [] );
    if ( ! is_array( $list ) ) {
        $list = [];
    }
    if ( ! in_array( $key, $list, true ) ) {
        $list[] = $key;
        update_option( $opt, array_slice( $list, -50 ), 'no' );
    }
}

/**
 * POST /s3/report-unreadable — o leitor web reporta um ficheiro ilegível.
 * Antes de pedir reenvio ao telemóvel, o SERVIDOR tenta curar sozinho: baixa o
 * ficheiro, tenta as chaves antigas do histórico e, se acertar, re-encripta com
 * a chave atual e atualiza o S3. `healed: true` = tenta tocar de novo já.
 */
function mp_handle_report_unreadable( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    $key = ltrim( (string) $request->get_param( 'key' ), '/' );
    if ( strpos( $key, 'players/' . (int) $row->id . '/' ) !== 0 ) {
        return rmbz_api_error( 'Chave fora da tua conta.', 403, 'forbidden' );
    }
    // Tenta a cura no servidor primeiro (chaves do histórico).
    $resp = wp_remote_get( mp_s3_presign( 'GET', $key, 300 ), [ 'timeout' => 90 ] );
    if ( ! is_wp_error( $resp ) && 200 === (int) wp_remote_retrieve_response_code( $resp ) ) {
        $healed = mp_cloud_decrypt_heal( (int) $row->id, $key, (string) wp_remote_retrieve_body( $resp ) );
        if ( null !== $healed ) {
            return rmbz_api_ok( [ 'queued' => false, 'healed' => true ] );
        }
    }
    // Sem chave que sirva → só o reenvio do telemóvel salva.
    mp_reup_add( (int) $row->id, $key );
    return rmbz_api_ok( [ 'queued' => true, 'healed' => false ] );
}

/** GET /s3/reupload-list — o app consome os pedidos de reenvio (lê e limpa). */
function mp_handle_reupload_list( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    $opt  = 'mp_reup_' . (int) $row->id;
    $list = get_option( $opt, [] );
    if ( ! is_array( $list ) ) {
        $list = [];
    }
    delete_option( $opt ); // consumido: o app assume a responsabilidade agora
    return rmbz_api_ok( [ 'keys' => array_values( $list ) ] );
}

/**
 * Chave de encriptação POR CONTA (AES-256): gerada uma vez no servidor e entregue
 * por HTTPS a todos os dispositivos da conta. Os ficheiros no S3 ficam cifrados
 * (lixo binário); o servidor conhece a chave, por isso o admin consegue abrir.
 */
function mp_cloud_key_for( $account_id ) {
    $opt = 'mp_cloudkey_' . (int) $account_id;
    $key = (string) get_option( $opt, '' );
    if ( $key === '' ) {
        $key = base64_encode( random_bytes( 32 ) );
        add_option( $opt, $key, '', 'no' );
    }
    return $key;
}

/**
 * ARQUIVA a chave atual no histórico antes de qualquer rotação/limpeza.
 * As chaves antigas NUNCA se perdem — é isso que torna os ficheiros com chave
 * antiga recuperáveis no servidor (sem reenvio do telemóvel).
 */
function mp_cloud_key_archive( $account_id ) {
    $cur = (string) get_option( 'mp_cloudkey_' . (int) $account_id, '' );
    if ( $cur === '' ) {
        return;
    }
    $opt  = 'mp_cloudkey_hist_' . (int) $account_id;
    $hist = get_option( $opt, [] );
    if ( ! is_array( $hist ) ) {
        $hist = [];
    }
    if ( ! in_array( $cur, $hist, true ) ) {
        $hist[] = $cur;
        update_option( $opt, array_slice( $hist, -10 ), 'no' );
    }
}

/** Todas as chaves conhecidas da conta: a ATUAL primeiro, depois o histórico. */
function mp_cloud_keys_all( $account_id ) {
    $keys = [ mp_cloud_key_for( (int) $account_id ) ];
    $hist = get_option( 'mp_cloudkey_hist_' . (int) $account_id, [] );
    if ( is_array( $hist ) ) {
        foreach ( array_reverse( $hist ) as $k ) {
            if ( is_string( $k ) && $k !== '' && ! in_array( $k, $keys, true ) ) {
                $keys[] = $k;
            }
        }
    }
    return $keys;
}

/**
 * O resultado parece áudio real? (o AES-CTR com chave errada só produz lixo.)
 * ATENÇÃO: o YouTube entrega muitas faixas em WebM/Opus (EBML 1A45DFA3) — sem
 * esta assinatura o admin declarava "ilegível" um ficheiro perfeitamente bom.
 */
function mp_audio_magic_ok( $data ) {
    return ( 0 === strpos( $data, 'ID3' ) )
        || ( strlen( $data ) > 1 && 0xFF === ord( $data[0] ) && 0xE0 === ( ord( $data[1] ) & 0xE0 ) )
        || ( 'ftyp' === substr( $data, 4, 4 ) )
        || ( 0 === strpos( $data, 'OggS' ) )
        || ( 0 === strpos( $data, 'fLaC' ) )
        || ( 0 === strpos( $data, 'RIFF' ) )
        || ( 0 === strpos( $data, "\x1A\x45\xDF\xA3" ) ); // WebM/Matroska (EBML)
}

/** Tira uma chave da lista de reenvio (já não é precisa: o ficheiro foi curado). */
function mp_reup_remove( $account_id, $key ) {
    $opt  = 'mp_reup_' . (int) $account_id;
    $list = get_option( $opt, [] );
    if ( is_array( $list ) && in_array( $key, $list, true ) ) {
        update_option( $opt, array_values( array_diff( $list, [ $key ] ) ), 'no' );
    }
}

/**
 * CURA DEFINITIVA: desencripta `<iv:16><ct>` tentando a chave atual E o
 * histórico. Se acertar com uma chave ANTIGA, re-encripta com a ATUAL e
 * substitui o ficheiro no S3 na hora — o problema desaparece para sempre,
 * sem reenvio do telemóvel. Devolve o áudio limpo, ou null se nenhuma
 * chave conhecida servir (aí sim, só o reenvio salva).
 */
function mp_cloud_decrypt_heal( $account_id, $s3_key, $raw ) {
    $iv = substr( $raw, 0, 16 );
    $ct = substr( $raw, 16 );
    foreach ( mp_cloud_keys_all( $account_id ) as $i => $b64 ) {
        $out = openssl_decrypt( $ct, 'aes-256-ctr', base64_decode( $b64 ), OPENSSL_RAW_DATA, $iv );
        if ( false === $out || ! mp_audio_magic_ok( $out ) ) {
            continue;
        }
        if ( $i > 0 ) {
            // Funcionou com uma chave antiga → re-encripta com a atual e atualiza o S3.
            $cur = base64_decode( mp_cloud_key_for( (int) $account_id ) );
            $niv = random_bytes( 16 );
            $nct = openssl_encrypt( $out, 'aes-256-ctr', $cur, OPENSSL_RAW_DATA, $niv );
            if ( false !== $nct ) {
                $put = wp_remote_request( mp_s3_presign( 'PUT', $s3_key, 300 ), [
                    'method'  => 'PUT',
                    'timeout' => 90,
                    'body'    => $niv . $nct,
                ] );
                // Curado → já não precisa de reenvio nem de nova tentativa.
                if ( ! is_wp_error( $put ) && (int) wp_remote_retrieve_response_code( $put ) < 300 ) {
                    mp_reup_remove( (int) $account_id, $s3_key );
                }
            }
        }
        return $out;
    }
    return null;
}

function mp_handle_s3_key( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    return rmbz_api_ok( [ 'key' => mp_cloud_key_for( (int) $row->id ), 'algo' => 'AES-256-CTR' ] );
}

/**
 * GET /s3/my-files — biblioteca na nuvem da conta, pronta para o leitor web:
 * músicas agrupadas por pasta, com URLs pré-assinadas (1 h) para faixa, capa e
 * meta.json, mais a chave de desencriptação da conta. Uma chamada chega.
 */
function mp_handle_s3_my_files( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    $prefix  = 'players/' . (int) $row->id . '/audio/';
    $err     = null;
    // Uma música moderna usa áudio + meta + capa. Contas com mais de ~330
    // faixas ultrapassam facilmente 1.000 objetos, portanto percorre todas as
    // páginas suportadas pelo helper em vez de truncar a biblioteca.
    $complete = false;
    $objects = mp_s3_list( $prefix, 10000, $err, $complete );
    if ( $err !== null ) {
        return rmbz_api_error( 'Falha ao listar a nuvem: ' . $err, 502, 'list_error' );
    }
    $songs = [];
    foreach ( $objects as $obj ) {
        $rel = substr( $obj['key'], strlen( $prefix ) );
        if ( $rel === '' || substr( $rel, -1 ) === '/' ) {
            continue;
        }
        $slash  = strpos( $rel, '/' );
        $folder = ( false === $slash ) ? $rel : substr( $rel, 0, $slash );
        $fname  = ( false === $slash ) ? $rel : substr( $rel, $slash + 1 );
        if ( ! isset( $songs[ $folder ] ) ) {
            $songs[ $folder ] = [ 'folder' => $folder, 'track' => null, 'cover' => null, 'meta' => null, 'size' => 0 ];
        }
        $songs[ $folder ]['size'] += (int) $obj['size'];
        $is_audio = ( 0 === strpos( $fname, 'track.' ) )
            || preg_match( '/\.(mp3|m4a|aac|ogg|wav|flac)(\.enc)?$/i', $fname );
        if ( $is_audio && null === $songs[ $folder ]['track'] ) {
            $songs[ $folder ]['track'] = [
                'key'  => $obj['key'],
                'size' => (int) $obj['size'],
                'enc'  => (bool) preg_match( '/\.enc$/i', $obj['key'] ),
                'url'  => mp_s3_presign( 'GET', $obj['key'], 3600 ),
            ];
        } elseif ( preg_match( '/\.(jpe?g|png|webp)$/i', $fname ) && null === $songs[ $folder ]['cover'] ) {
            $songs[ $folder ]['cover'] = [ 'key' => $obj['key'], 'url' => mp_s3_presign( 'GET', $obj['key'], 3600 ) ];
        } elseif ( preg_match( '/\.json$/i', $fname ) && null === $songs[ $folder ]['meta'] ) {
            $songs[ $folder ]['meta'] = [ 'key' => $obj['key'], 'url' => mp_s3_presign( 'GET', $obj['key'], 3600 ) ];
        }
    }
    // Sem faixa de áudio = pasta órfã (ex.: só capa) — não interessa ao leitor.
    $songs = array_values( array_filter( $songs, function ( $s ) { return null !== $s['track']; } ) );
    return rmbz_api_ok( [
        'songs'      => $songs,
        'complete'   => (bool) $complete,
        'version'    => (int) get_option( 'mp_cloudver_' . (int) $row->id, 0 ),
        'expires_in' => 3600,
        'crypto_key' => mp_cloud_key_for( (int) $row->id ),
        'algo'       => 'AES-256-CTR',
    ] );
}

/**
 * One raw S3 page for the web player. Unlike /my-files this endpoint does not
 * generate thousands of signed URLs and has no total-library object cap.
 */
function mp_handle_s3_web_catalog( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }

    $cursor = (string) $request->get_param( 'cursor' );
    if ( strlen( $cursor ) > 4096 || preg_match( '/[\x00-\x1F\x7F]/', $cursor ) ) {
        return rmbz_api_error( 'Cursor inválido.', 400, 'invalid_cursor' );
    }
    $prefix = 'players/' . (int) $row->id . '/audio/';
    $error = null;
    $page = mp_s3_list_page( $prefix, $cursor, $error );
    if ( null !== $error ) {
        return rmbz_api_error( 'Falha ao listar a nuvem: ' . $error, 502, 'list_error' );
    }

    $objects = [];
    foreach ( $page['objects'] as $object ) {
        $relative = substr( $object['key'], strlen( $prefix ) );
        if ( '' === $relative || '/' === substr( $relative, -1 ) ) {
            continue;
        }
        $slash = strpos( $relative, '/' );
        $folder = false === $slash ? $relative : substr( $relative, 0, $slash );
        $name = false === $slash ? $relative : substr( $relative, $slash + 1 );
        $kind = 'other';
        if ( 0 === strpos( $name, 'track.' ) || preg_match( '/\.(mp3|m4a|aac|ogg|wav|flac)(\.enc)?$/i', $name ) ) {
            $kind = 'track';
        } elseif ( preg_match( '/\.(jpe?g|png|webp)$/i', $name ) ) {
            $kind = 'cover';
        } elseif ( preg_match( '/\.json$/i', $name ) ) {
            $kind = 'meta';
        }
        if ( 'other' === $kind ) {
            continue;
        }
        $objects[] = [
            'folder'   => $folder,
            'name'     => $name,
            'key'      => $object['key'],
            'kind'     => $kind,
            'size'     => (int) $object['size'],
            'modified' => (string) $object['modified'],
            'enc'      => 'track' === $kind && (bool) preg_match( '/\.enc$/i', $object['key'] ),
        ];
    }

    return rmbz_api_ok( [
        'objects'    => $objects,
        'cursor'     => $page['next_cursor'],
        'has_more'   => $page['has_more'],
        'version'    => (int) get_option( 'mp_cloudver_' . (int) $row->id, 0 ),
        'crypto_key' => mp_cloud_key_for( (int) $row->id ),
    ] );
}

/** Resolve only the objects currently needed by the web UI (max 100). */
function mp_handle_s3_object_urls( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    $keys = $request->get_param( 'keys' );
    if ( ! is_array( $keys ) || count( $keys ) < 1 || count( $keys ) > 100 ) {
        return rmbz_api_error( 'Envia entre 1 e 100 chaves.', 422, 'invalid_keys' );
    }
    $prefix = 'players/' . (int) $row->id . '/audio/';
    $urls = [];
    foreach ( array_values( array_unique( array_map( 'strval', $keys ) ) ) as $key ) {
        $key = ltrim( $key, '/' );
        if ( 0 !== strpos( $key, $prefix ) ) {
            return rmbz_api_error( 'Chave fora da tua biblioteca.', 403, 'forbidden' );
        }
        $url = mp_s3_presign( 'GET', $key, 900 );
        if ( null === $url ) {
            return rmbz_api_error( 'Falha a assinar os ficheiros.', 502, 'sign_error' );
        }
        $urls[ $key ] = $url;
    }
    return rmbz_api_ok( [ 'urls' => $urls, 'expires_in' => 900 ] );
}

/** Número fixo de dispositivos que uma conta VIP pode guardar. */
function mp_backup_device_limit() {
    return 5;
}

/**
 * Reúne os dispositivos do formato atual e do legado. Quando o mesmo slot
 * existe nas duas estruturas, Devices/ tem prioridade e evita dupla contagem.
 */
function mp_backup_items_for_account( $account_id ) {
    $account_id = (int) $account_id;
    $items = [];

    foreach ( mp_s3_list( "players/{$account_id}/Devices/", 200 ) as $obj ) {
        if ( ! preg_match( '#/Devices/([^/]+)/data\.json$#i', $obj['key'], $match ) ) {
            continue;
        }
        $slot_id = sanitize_key( $match[1] );
        if ( '' === $slot_id ) {
            continue;
        }
        $items[ $slot_id ] = [
            'key'       => $obj['key'],
            'slot_id'   => $slot_id,
            'name'      => $slot_id . '.json',
            'size'      => (int) $obj['size'],
            'modified'  => (string) $obj['modified'],
            'legacy'    => false,
        ];
    }

    foreach ( mp_s3_list( "players/{$account_id}/backup/", 100 ) as $obj ) {
        if ( ! preg_match( '/\.json$/i', $obj['key'] ) ) {
            continue;
        }
        $slot_id = sanitize_key( pathinfo( basename( $obj['key'] ), PATHINFO_FILENAME ) );
        if ( '' === $slot_id || isset( $items[ $slot_id ] ) ) {
            continue;
        }
        $items[ $slot_id ] = [
            'key'       => $obj['key'],
            'slot_id'   => $slot_id,
            'name'      => basename( $obj['key'] ),
            'size'      => (int) $obj['size'],
            'modified'  => (string) $obj['modified'],
            'legacy'    => true,
        ];
    }

    return array_values( $items );
}

/**
 * GET /backup/list — cópias de segurança da conta (uma por dispositivo), com
 * URL pré-assinada de leitura. Também expõe o formato antigo para migração.
 */
function mp_handle_backup_list( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    if ( (int) $row->is_vip !== 1 ) {
        return rmbz_api_error( 'As cópias por dispositivo são exclusivas para contas VIP.', 403, 'vip_required' );
    }

    $items = mp_backup_items_for_account( (int) $row->id );
    usort( $items, function ( $a, $b ) {
        return strcmp( (string) $b['modified'], (string) $a['modified'] );
    } );
    foreach ( $items as &$item ) {
        $item['url'] = mp_s3_presign( 'GET', $item['key'], 900 );
    }
    unset( $item );

    return rmbz_api_ok( [
        'backups' => $items,
        'limit'   => mp_backup_device_limit(),
        'root'    => 'players/' . (int) $row->id . '/Devices/',
    ] );
}

/** O armazenamento na cloud está ligado e configurado? */
function mp_s3_enabled() {
    return (bool) get_option( 'mp_s3_enabled', 0 )
        && get_option( 'mp_s3_bucket', '' ) !== ''
        && get_option( 'mp_s3_key', '' ) !== ''
        && get_option( 'mp_s3_secret', '' ) !== '';
}

/** Host do bucket (S3 nativo ou endpoint compatível: Wasabi, Spaces, R2…). */
function mp_s3_host() {
    $bucket   = get_option( 'mp_s3_bucket', '' );
    $region   = get_option( 'mp_s3_region', 'us-east-1' );
    $endpoint = trim( (string) get_option( 'mp_s3_endpoint', '' ) );
    if ( $endpoint !== '' ) {
        $endpoint = preg_replace( '#^https?://#', '', rtrim( $endpoint, '/' ) );
        return $bucket . '.' . $endpoint;
    }
    return $bucket . '.s3.' . $region . '.amazonaws.com';
}

/**
 * Gera uma URL pré-assinada (AWS Signature V4, query-string) para o método dado.
 * Assina apenas o header `host` e usa UNSIGNED-PAYLOAD, para o app poder
 * enviar o Content-Type livremente.
 */
function mp_s3_presign( $method, $key, $expires = 900, $extra_query = [] ) {
    $access = (string) get_option( 'mp_s3_key', '' );
    $secret = (string) get_option( 'mp_s3_secret', '' );
    $region = (string) get_option( 'mp_s3_region', 'us-east-1' );
    if ( $access === '' || $secret === '' ) {
        return null;
    }
    $key     = ltrim( (string) $key, '/' );
    $host    = mp_s3_host();
    $service = 's3';
    $now       = time();
    $amzdate   = gmdate( 'Ymd\THis\Z', $now );
    $datestamp = gmdate( 'Ymd', $now );

    // Encode cada segmento do caminho mas mantém as barras.
    $canonical_uri = '/' . str_replace( '%2F', '/', rawurlencode( $key ) );

    $credential = $access . '/' . $datestamp . '/' . $region . '/' . $service . '/aws4_request';
    $query = array_merge( $extra_query, [
        'X-Amz-Algorithm'     => 'AWS4-HMAC-SHA256',
        'X-Amz-Credential'    => $credential,
        'X-Amz-Date'          => $amzdate,
        'X-Amz-Expires'       => (string) $expires,
        'X-Amz-SignedHeaders' => 'host',
    ] );
    ksort( $query );
    $pairs = [];
    foreach ( $query as $k => $v ) {
        $pairs[] = rawurlencode( $k ) . '=' . rawurlencode( $v );
    }
    $canonical_query = implode( '&', $pairs );

    $canonical_headers = "host:$host\n";
    $signed_headers    = 'host';
    $payload_hash      = 'UNSIGNED-PAYLOAD';
    $canonical_request = "$method\n$canonical_uri\n$canonical_query\n$canonical_headers\n$signed_headers\n$payload_hash";

    $scope          = "$datestamp/$region/$service/aws4_request";
    $string_to_sign = "AWS4-HMAC-SHA256\n$amzdate\n$scope\n" . hash( 'sha256', $canonical_request );

    $k_date    = hash_hmac( 'sha256', $datestamp, 'AWS4' . $secret, true );
    $k_region  = hash_hmac( 'sha256', $region, $k_date, true );
    $k_service = hash_hmac( 'sha256', $service, $k_region, true );
    $k_signing = hash_hmac( 'sha256', 'aws4_request', $k_service, true );
    $signature = hash_hmac( 'sha256', $string_to_sign, $k_signing );

    return "https://$host$canonical_uri?$canonical_query&X-Amz-Signature=$signature";
}

/**
 * Lista objetos do bucket por prefixo (ListObjectsV2) — usado pela aba «Nuvem»
 * do admin. Devolve [['key','size','modified'], …] (até $max itens), seguindo
 * automaticamente o token de continuação do S3.
 */
function mp_s3_list( $prefix, $max = 1000, &$error = null, &$complete = null ) {
    $error = null;
    $complete = false;
    if ( ! mp_s3_enabled() ) {
        $error = 'Armazenamento não configurado.';
        return [];
    }
    $out = [];
    $remaining = min( 10000, max( 1, (int) $max ) );
    $continuation = '';
    $is_truncated = false;
    do {
        $query = [
            'list-type' => '2',
            'prefix'    => (string) $prefix,
            'max-keys'  => (string) min( 1000, $remaining ),
        ];
        if ( '' !== $continuation ) {
            $query['continuation-token'] = $continuation;
        }
        $url = mp_s3_presign( 'GET', '', 300, $query );
        if ( null === $url ) {
            $error = 'Falha a assinar o pedido.';
            return [];
        }
        $resp = wp_remote_get( $url, [ 'timeout' => 25 ] );
        if ( is_wp_error( $resp ) ) {
            $error = $resp->get_error_message();
            return [];
        }
        if ( 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
            $error = mp_s3_error_code( $resp ) . ' (HTTP ' . wp_remote_retrieve_response_code( $resp ) . ')';
            return [];
        }
        $body = (string) wp_remote_retrieve_body( $resp );
        $page_count = 0;
        if ( preg_match_all( '/<Contents>(.*?)<\/Contents>/s', $body, $blocks ) ) {
            foreach ( $blocks[1] as $b ) {
                // ENT_QUOTES|ENT_HTML5: o S3 escapa &apos; (apóstrofo) no XML e o
                // default do PHP (HTML 4.01) NÃO o descodifica — chaves com ' em
                // nomes de artistas (ex.: I'black) ficavam erradas em TODO o lado.
                $key  = preg_match( '/<Key>([^<]+)<\/Key>/', $b, $m )
                    ? html_entity_decode( $m[1], ENT_QUOTES | ENT_HTML5 )
                    : '';
                $size = preg_match( '/<Size>(\d+)<\/Size>/', $b, $m ) ? (int) $m[1] : 0;
                $mod  = preg_match( '/<LastModified>([^<]+)<\/LastModified>/', $b, $m ) ? $m[1] : '';
                if ( $key !== '' ) {
                    $out[] = [ 'key' => $key, 'size' => $size, 'modified' => $mod ];
                    $page_count++;
                }
            }
        }
        $remaining -= $page_count;
        $is_truncated = false !== strpos( $body, '<IsTruncated>true</IsTruncated>' );
        $continuation = preg_match( '/<NextContinuationToken>([^<]+)<\/NextContinuationToken>/', $body, $tm )
            ? html_entity_decode( $tm[1], ENT_QUOTES | ENT_HTML5 )
            : '';
    } while ( $remaining > 0 && $is_truncated && '' !== $continuation );
    $complete = ! $is_truncated || '' === $continuation;
    return $out;
}

/** Fetches exactly one ListObjectsV2 page and exposes its opaque next cursor. */
function mp_s3_list_page( $prefix, $cursor = '', &$error = null ) {
    $error = null;
    if ( ! mp_s3_enabled() ) {
        $error = 'Armazenamento não configurado.';
        return [ 'objects' => [], 'next_cursor' => '', 'has_more' => false ];
    }
    $query = [
        'list-type' => '2',
        'prefix'    => (string) $prefix,
        'max-keys'  => '1000',
    ];
    if ( '' !== $cursor ) {
        $query['continuation-token'] = (string) $cursor;
    }
    $url = mp_s3_presign( 'GET', '', 300, $query );
    if ( null === $url ) {
        $error = 'Falha a assinar o pedido.';
        return [ 'objects' => [], 'next_cursor' => '', 'has_more' => false ];
    }
    $response = wp_remote_get( $url, [ 'timeout' => 25 ] );
    if ( is_wp_error( $response ) ) {
        $error = $response->get_error_message();
        return [ 'objects' => [], 'next_cursor' => '', 'has_more' => false ];
    }
    if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
        $error = mp_s3_error_code( $response ) . ' (HTTP ' . wp_remote_retrieve_response_code( $response ) . ')';
        return [ 'objects' => [], 'next_cursor' => '', 'has_more' => false ];
    }

    $body = (string) wp_remote_retrieve_body( $response );
    $objects = [];
    if ( preg_match_all( '/<Contents>(.*?)<\/Contents>/s', $body, $blocks ) ) {
        foreach ( $blocks[1] as $block ) {
            $key = preg_match( '/<Key>([^<]+)<\/Key>/', $block, $match )
                ? html_entity_decode( $match[1], ENT_QUOTES | ENT_HTML5 )
                : '';
            if ( '' === $key ) {
                continue;
            }
            $size = preg_match( '/<Size>(\d+)<\/Size>/', $block, $match ) ? (int) $match[1] : 0;
            $modified = preg_match( '/<LastModified>([^<]+)<\/LastModified>/', $block, $match ) ? $match[1] : '';
            $objects[] = [ 'key' => $key, 'size' => $size, 'modified' => $modified ];
        }
    }
    $has_more = false !== strpos( $body, '<IsTruncated>true</IsTruncated>' );
    $next_cursor = preg_match( '/<NextContinuationToken>([^<]+)<\/NextContinuationToken>/', $body, $match )
        ? html_entity_decode( $match[1], ENT_QUOTES | ENT_HTML5 )
        : '';
    return [
        'objects'     => $objects,
        'next_cursor' => $next_cursor,
        'has_more'    => $has_more && '' !== $next_cursor,
    ];
}

/**
 * Teste real de ligação (usado pelo botão do admin): PUT + GET + DELETE de um
 * pequeno objeto. Devolve ['ok' => bool, 'msg' => detalhe legível].
 */
function mp_s3_selftest() {
    if ( ! mp_s3_enabled() ) {
        return [ 'ok' => false, 'msg' => 'Preenche bucket, região e as duas chaves, marca «Ativar» e guarda primeiro.' ];
    }
    $key  = 'system/selftest-' . time() . '.txt';
    $body = '1MZ selftest ' . gmdate( 'c' );

    $put = wp_remote_request( mp_s3_presign( 'PUT', $key, 120 ), [
        'method'  => 'PUT',
        'body'    => $body,
        'timeout' => 20,
        'headers' => [ 'Content-Type' => 'text/plain' ],
    ] );
    if ( is_wp_error( $put ) ) {
        return [ 'ok' => false, 'msg' => 'Upload falhou: ' . $put->get_error_message() ];
    }
    $code = (int) wp_remote_retrieve_response_code( $put );
    if ( $code < 200 || $code >= 300 ) {
        return [ 'ok' => false, 'msg' => "Upload devolveu HTTP $code (" . mp_s3_error_code( $put ) . '). Confere bucket/região/chaves.' ];
    }

    $get = wp_remote_get( mp_s3_presign( 'GET', $key, 120 ), [ 'timeout' => 20 ] );
    if ( is_wp_error( $get ) || 200 !== (int) wp_remote_retrieve_response_code( $get )
        || wp_remote_retrieve_body( $get ) !== $body ) {
        return [ 'ok' => false, 'msg' => 'Upload OK mas a leitura falhou — confere a permissão s3:GetObject na policy.' ];
    }

    wp_remote_request( mp_s3_presign( 'DELETE', $key, 120 ), [ 'method' => 'DELETE', 'timeout' => 20 ] );
    return [ 'ok' => true, 'msg' => 'Tudo a funcionar: upload, leitura e remoção no bucket OK. O app já pode guardar músicas na nuvem.' ];
}

/** Extrai o <Code> do XML de erro do S3 (ex.: SignatureDoesNotMatch, NoSuchBucket). */
function mp_s3_error_code( $resp ) {
    $b = (string) wp_remote_retrieve_body( $resp );
    if ( preg_match( '/<Code>([^<]+)<\/Code>/', $b, $m ) ) {
        return $m[1];
    }
    return substr( wp_strip_all_tags( $b ), 0, 120 );
}

/** Prefixo (pasta) permitido para esta conta consoante o tipo de conteúdo. */
function mp_s3_prefix_for( $kind, $account_id ) {
    switch ( $kind ) {
        case 'music':  return "artists/$account_id/music/";
        case 'artist': return "artists/$account_id/data/";
        case 'backup': return "players/$account_id/Devices/";
        case 'audio':  return "players/$account_id/audio/"; // cópia VIP das músicas do utilizador
        default:       return '';
    }
}

/**
 * Quota efetiva de músicas na nuvem desta conta, em MB (0 = ilimitado).
 *
 * VIP: quota própria (mp_vip_quota_gb, padrão 1024 GB = 1 TB) DESLIZANTE —
 * cheia com 30+ dias de VIP restantes e proporcional aos dias que faltam
 * (ex.: 15 dias → metade; 3 dias → 10%). VIP vitalício = quota cheia.
 * Não-VIP: quota normal do admin (mp_cloud_quota_mb).
 */
function mp_effective_quota_mb( $row ) {
    $base_mb = (int) get_option( 'mp_cloud_quota_mb', 0 );
    if ( ! $row || (int) $row->is_vip !== 1 ) {
        return $base_mb;
    }
    $vip_gb = (int) get_option( 'mp_vip_quota_gb', 1024 );
    if ( $vip_gb <= 0 ) {
        return $base_mb; // quota VIP desativada → segue a normal
    }
    $vip_mb = $vip_gb * 1024;
    if ( empty( $row->vip_until ) ) {
        return $vip_mb; // vitalício
    }
    $left_days = ( strtotime( $row->vip_until ) - time() ) / DAY_IN_SECONDS;
    if ( $left_days <= 0 ) {
        return $base_mb; // VIP expirado
    }
    $factor = min( 1, $left_days / 30 );
    return (int) max( $base_mb, floor( $vip_mb * $factor ) );
}

/**
 * GET /s3/quota — o espaço na nuvem desta conta: quanto está usado e a quota
 * efetiva DESLIZANTE (VIP: proporcional aos dias restantes). O app mostra isto
 * em "Nuvem e dados".
 */
function mp_handle_s3_quota( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    $used = 0;
    foreach ( mp_s3_list( mp_s3_prefix_for( 'audio', (int) $row->id ), 1000 ) as $o ) {
        $used += (int) $o['size'];
    }
    $days_left = null;
    if ( (int) $row->is_vip === 1 && ! empty( $row->vip_until ) ) {
        $days_left = max( 0, (int) ceil( ( strtotime( $row->vip_until ) - time() ) / DAY_IN_SECONDS ) );
    }
    return rmbz_api_ok( [
        'used_bytes' => $used,
        'quota_mb'   => mp_effective_quota_mb( $row ), // 0 = ilimitado
        'vip'        => (int) $row->is_vip === 1,
        'days_left'  => $days_left, // null = não-VIP ou vitalício
        'vip_source' => mp_vip_source( $row ),
        'audio_upload_allowed' => mp_account_cloud_audio_allowed( $row ),
        'data_sync_allowed' => true,
    ] );
}

function mp_handle_s3_upload_url( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    $kind     = sanitize_key( (string) $request->get_param( 'kind' ) );
    $filename = sanitize_file_name( (string) $request->get_param( 'filename' ) );
    if ( $filename === '' ) {
        $filename = 'ficheiro';
    }
    $prefix = mp_s3_prefix_for( $kind, (int) $row->id );
    if ( $prefix === '' ) {
        return rmbz_api_error( 'Tipo de conteúdo inválido.', 400, 'bad_kind' );
    }

    if ( 'audio' === $kind && ! mp_account_cloud_audio_allowed( $row ) ) {
        return rmbz_api_error(
            'O VIP ganho por anúncios sincroniza playlists, gostos e definições, mas não inclui o envio de músicas para a nuvem.',
            403,
            'rewarded_vip_data_only'
        );
    }

    // Quota por utilizador nas músicas (0 = ilimitado; definida no admin).
    // VIP tem quota própria DESLIZANTE: cheia (ex.: 1 TB) com 30+ dias de VIP
    // restantes, e vai caindo proporcionalmente quando falta pouco para expirar.
    if ( 'audio' === $kind ) {
        $quota_mb = mp_effective_quota_mb( $row );
        if ( $quota_mb > 0 ) {
            $used = 0;
            foreach ( mp_s3_list( $prefix, 1000 ) as $o ) {
                $used += $o['size'];
            }
            if ( $used >= $quota_mb * 1048576 ) {
                $nice = $quota_mb >= 1024 ? round( $quota_mb / 1024, 1 ) . ' GB' : $quota_mb . ' MB';
                return rmbz_api_error(
                    "Quota da nuvem atingida ({$nice}). A quota VIP acompanha os dias de VIP restantes — renova o VIP ou apaga músicas antigas.",
                    403,
                    'quota'
                );
            }
        }
    }
    // Cópias VIP: cada slot vive em Devices/{id}/data.json. Aceita também o
    // caminho antigo {id}.json enviado por versões anteriores do Android.
    $backup_key = '';
    if ( 'backup' === $kind ) {
        if ( (int) $row->is_vip !== 1 ) {
            return rmbz_api_error( 'As cópias por dispositivo são exclusivas para contas VIP.', 403, 'vip_required' );
        }

        $requested = trim( (string) ( $request->get_param( 'path' ) ?: $filename ) );
        $requested = str_replace( '\\', '/', $requested );
        if ( preg_match( '#^([^/]+)(?:/data\.json|\.json)$#i', $requested, $match ) ) {
            $slot_id = sanitize_key( $match[1] );
        } else {
            $slot_id = sanitize_key( pathinfo( basename( $requested ), PATHINFO_FILENAME ) );
        }
        if ( '' === $slot_id ) {
            return rmbz_api_error( 'Identificador do dispositivo inválido.', 400, 'bad_device' );
        }

        $existing = [];
        foreach ( mp_backup_items_for_account( (int) $row->id ) as $item ) {
            $existing[ $item['slot_id'] ] = true;
        }
        $limit = mp_backup_device_limit();
        if ( ! isset( $existing[ $slot_id ] ) && count( $existing ) >= $limit ) {
            return rmbz_api_error(
                "Limite VIP de {$limit} dispositivos atingido. Escolhe um dispositivo existente para substituir.",
                403,
                'device_limit'
            );
        }
        $backup_key = $prefix . $slot_id . '/data.json';
    }

    // Caminho controlado pelo app (ex.: "Artista - Título-123/track.enc") para a
    // estrutura de pasta-por-música; sanitizado para nunca sair do prefixo da conta.
    $path = trim( (string) $request->get_param( 'path' ) );
    if ( '' !== $backup_key ) {
        $key = $backup_key;
    } elseif ( $path !== '' ) {
        $path = ltrim( str_replace( [ '..', '\\' ], '', $path ), '/' );
        $path = preg_replace( '#[^\p{L}\p{N} ._\-/()\[\]&,\']#u', '_', $path );
        $key  = $prefix . $path;
    } else {
        // backup = nome fixo (substitui); restantes = nome único.
        $key = ( $kind === 'backup' )
            ? $prefix . $filename
            : $prefix . time() . '-' . wp_generate_password( 6, false ) . '-' . $filename;
    }

    $url = mp_s3_presign( 'PUT', $key, 900 );
    if ( $url === null ) {
        return rmbz_api_error( 'Falha a assinar o upload.', 502, 'sign_error' );
    }
    if ( in_array( $kind, [ 'audio', 'backup' ], true ) ) {
        mp_cloud_bump( (int) $row->id ); // o web recarrega sozinho
    }
    return rmbz_api_ok( [ 'url' => $url, 'key' => $key, 'expires_in' => 900 ] );
}

function mp_handle_s3_download_url( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    $key = ltrim( (string) $request->get_param( 'key' ), '/' );
    if ( $key === '' ) {
        return rmbz_api_error( 'Sem chave.', 400, 'empty' );
    }
    // Conteúdo de artistas é catálogo público (qualquer conta pode ler);
    // o backup só pode ser lido pelo próprio dono.
    $own_backup = ( strpos( $key, 'players/' . (int) $row->id . '/' ) === 0 );
    $is_artist  = ( strpos( $key, 'artists/' ) === 0 );
    if ( ! $own_backup && ! $is_artist ) {
        return rmbz_api_error( 'Sem permissão para este ficheiro.', 403, 'forbidden' );
    }
    $url = mp_s3_presign( 'GET', $key, 900 );
    if ( $url === null ) {
        return rmbz_api_error( 'Falha a assinar o download.', 502, 'sign_error' );
    }
    return rmbz_api_ok( [ 'url' => $url, 'key' => $key, 'expires_in' => 900 ] );
}

function mp_handle_s3_delete( WP_REST_Request $request ) {
    $row = mp_account_from_request( $request );
    if ( ! $row ) {
        return rmbz_api_error( 'Inicia sessão.', 401, 'unauthorized' );
    }
    if ( ! mp_s3_enabled() ) {
        return rmbz_api_error( 'Armazenamento na cloud indisponível.', 403, 'disabled' );
    }
    $key = ltrim( (string) $request->get_param( 'key' ), '/' );
    // Só pode apagar dentro das suas próprias pastas.
    $own = ( strpos( $key, 'artists/' . (int) $row->id . '/' ) === 0 )
        || ( strpos( $key, 'players/' . (int) $row->id . '/' ) === 0 );
    if ( $key === '' || ! $own ) {
        return rmbz_api_error( 'Sem permissão para apagar este ficheiro.', 403, 'forbidden' );
    }

    // Chave a terminar em "/" = apagar a PASTA inteira (áudio + capa + meta + o que houver).
    if ( substr( $key, -1 ) === '/' ) {
        $n = 0;
        foreach ( mp_s3_list( $key, 1000 ) as $obj ) {
            $resp = wp_remote_request( mp_s3_presign( 'DELETE', $obj['key'], 300 ), [ 'method' => 'DELETE', 'timeout' => 20 ] );
            if ( ! is_wp_error( $resp ) && (int) wp_remote_retrieve_response_code( $resp ) < 300 ) {
                $n++;
            }
        }
        mp_cloud_bump( (int) $row->id );
        return rmbz_api_ok( [ 'deleted' => $n > 0, 'count' => $n, 'key' => $key ] );
    }

    $url  = mp_s3_presign( 'DELETE', $key, 300 );
    $resp = wp_remote_request( $url, [ 'method' => 'DELETE', 'timeout' => 20 ] );
    if ( is_wp_error( $resp ) ) {
        return rmbz_api_error( 'Falha ao apagar.', 502, 'delete_error' );
    }
    mp_cloud_bump( (int) $row->id );
    return rmbz_api_ok( [ 'deleted' => true, 'key' => $key ] );
}
