<?php
/**
 * 1MZ For Artists — Projects Controller (REST)
 *
 * Expõe à app o sistema "Ganhar Vídeo" / Videoclipes que existia apenas
 * no site (cenário `win_video` do form + tab "Contratos" da minha-conta).
 *
 * Endpoints:
 *   GET  /projects                       Lista projectos do artista
 *   GET  /projects/options               Cenários (basic/professional/cinema)
 *   GET  /projects/{id}                  Detalhe completo
 *   POST /projects                       Criar candidatura
 *   POST /projects/{id}/choose-budget    Escolher localização
 *   POST /projects/{id}/accept-contract  Aceitar contrato + KYC photo
 *   POST /projects/{id}/pay              Pagar produção
 *   POST /projects/{id}/feedback         Enviar mensagem ao admin
 *
 * Regras (paridade com site):
 *   - Subscrição VIP activa OBRIGATÓRIA com perm 'video'
 *   - Apenas 1 projecto pendente (production_status='pending_review') por artista
 *
 * @package 1MZ_For_Artists
 * @since 1.6.27
 */

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

if ( ! defined( 'RMBZ_API_PROJECTS_VERSION' ) ) {
    define( 'RMBZ_API_PROJECTS_VERSION', '1.6.27.2' );
}


/* ============================================================================
 * [1.6.118.56] HELPER — Calcular obligatory_cost de um projecto
 *
 * Único ponto de verdade para o cálculo do valor que o artista TEM mesmo de
 * pagar à 1MZ no modo misto. Usado tanto pelo /pay da API como pelo
 * onemz_pay_project_cost do form-handler do site.
 *
 * REGRA:
 *   - Item must_pay=1 (obrigatório)             → conta SEMPRE para o total
 *   - Item must_pay=0 E artist_assumes=0        → conta (artista deixou à 1MZ)
 *   - Item must_pay=0 E artist_assumes=1        → NÃO conta (artista assumiu)
 *
 * FALLBACK (back-compat para projectos legados ou sem scenario_details):
 *   - Se scenario_details é vazio ou inválido → devolve scenario_cost bruto
 *   - Se o cálculo der 0 mas scenario_cost > 0 → devolve scenario_cost
 *     (defesa contra bug de items todos legados sem must_pay)
 *
 * @param object $project Row da tabela rmbz_artist_projects com pelo menos
 *                        as colunas scenario_details (JSON) e scenario_cost.
 * @return float Valor obrigatório em MT.
 * ============================================================================ */
if ( ! function_exists( 'rmbz_calc_project_obligatory_cost' ) ) {
    function rmbz_calc_project_obligatory_cost( $project ) {
        $scenario_cost = isset( $project->scenario_cost ) ? (float) $project->scenario_cost : 0.0;
        $details_raw   = isset( $project->scenario_details ) ? $project->scenario_details : '';
        $details       = ! empty( $details_raw ) ? json_decode( $details_raw, true ) : null;

        if ( ! is_array( $details ) || empty( $details ) ) {
            return $scenario_cost; // back-compat
        }

        $obligatory = 0.0;
        foreach ( $details as $it ) {
            $cost     = isset( $it['cost'] ) ? (float) $it['cost'] : 0.0;
            $must_pay = isset( $it['must_pay'] ) ? (int) $it['must_pay'] : 1; // default obrigatório
            $assumes  = ! empty( $it['artist_assumes'] ) ? 1 : 0;
            // Coerência defensiva: obrigatório nunca tem assume
            if ( $must_pay === 1 ) $assumes = 0;

            // Conta para obligatory se: obrigatório OU (negociável E artista NÃO assumiu)
            if ( $must_pay === 1 || $assumes === 0 ) {
                $obligatory += $cost;
            }
        }

        // Defesa contra cálculo 0 acidental quando há scenario_cost legado
        if ( $obligatory <= 0 && $scenario_cost > 0 ) {
            return $scenario_cost;
        }

        return $obligatory;
    }
}


/* ============================================================================
 * REGISTO DOS ENDPOINTS
 * ============================================================================ */

add_action( 'rest_api_init', 'rmbz_api_register_projects_routes' );

function rmbz_api_register_projects_routes() {
    $ns   = RMBZ_API_NAMESPACE;
    $auth = 'rmbz_api_projects_permission';

    register_rest_route( $ns, '/projects', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_projects_list',
        'permission_callback' => $auth,
    ] );

    register_rest_route( $ns, '/projects/options', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_projects_options',
        'permission_callback' => $auth,
    ] );

    register_rest_route( $ns, '/projects/(?P<id>\d+)', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_project_detail',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    register_rest_route( $ns, '/projects', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_create',
        'permission_callback' => $auth,
    ] );

    register_rest_route( $ns, '/projects/(?P<id>\d+)/choose-budget', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_choose_budget',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    register_rest_route( $ns, '/projects/(?P<id>\d+)/accept-contract', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_accept_contract',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    // [1.6.118.45] Endpoint para artista decidir items adicionados pós-contrato (mini-prompt no app).
    register_rest_route( $ns, '/projects/(?P<id>\d+)/decide-items', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_decide_items',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    register_rest_route( $ns, '/projects/(?P<id>\d+)/pay', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_pay',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    register_rest_route( $ns, '/projects/(?P<id>\d+)/feedback', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_feedback',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    // [1.6.383] Pagar a multa por atraso com o saldo da carteira (PIN).
    register_rest_route( $ns, '/projects/pay-fine', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_pay_fine',
        'permission_callback' => $auth,
        'args' => [
            'pin' => [ 'required' => true, 'type' => 'string' ],
        ],
    ] );

    // [1.5.398] Responder ao pedido de transferência do projeto (aceitar exige termos).
    register_rest_route( $ns, '/projects/transfer-respond', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_transfer_respond',
        'permission_callback' => $auth,
        'args' => [
            'project_id'     => [ 'required' => true, 'type' => 'integer' ],
            'decision'       => [ 'required' => true, 'type' => 'string' ],
            'terms_accepted' => [ 'required' => false ],
        ],
    ] );

    // [1.5.401] Verificar se um número tem conta 1MZ ("artista válido" no form).
    register_rest_route( $ns, '/projects/feat-check', [
        'methods'             => 'GET',
        'callback'            => 'rmbz_api_handle_feat_check',
        'permission_callback' => $auth,
        'args' => [
            'tel' => [ 'required' => true, 'type' => 'string' ],
        ],
    ] );

    // [1.5.401] Participante responde ao convite de participação (feat).
    // Aceitar exige os Termos da Participação; recusar CANCELA o projeto.
    register_rest_route( $ns, '/projects/feat-respond', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_feat_respond',
        'permission_callback' => $auth,
        'args' => [
            'project_id'     => [ 'required' => true, 'type' => 'integer' ],
            'decision'       => [ 'required' => true, 'type' => 'string' ],
            'terms_accepted' => [ 'required' => false ],
        ],
    ] );

    // [1.6.27.2] Artista pode recusar o contrato com motivo. Define
    // contract_rejected = true no extra_data; admin é notificado e pode
    // enviar versão revista. Replica onemz_reject_contract_action do
    // form-handler.php (linhas 1285-1311).
    register_rest_route( $ns, '/projects/(?P<id>\d+)/reject-contract', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_reject_contract',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    // [1.6.118.64] Datas de gravação — artista confirma as datas do admin.
    register_rest_route( $ns, '/projects/(?P<id>\d+)/confirm-filming-dates', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_confirm_filming',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );

    // [1.6.118.64] Datas de gravação — artista propõe datas alternativas.
    register_rest_route( $ns, '/projects/(?P<id>\d+)/propose-filming-dates', [
        'methods'             => 'POST',
        'callback'            => 'rmbz_api_handle_project_propose_filming',
        'permission_callback' => $auth,
        'args' => [
            'id' => [ 'required' => true, 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
        ],
    ] );
}


/* ============================================================================
 * HELPERS PRIVADOS
 * ============================================================================ */

/**
 * Resolve telemóvel autenticado (com fallback re-auth — mesma técnica
 * dos outros controllers).
 */
function rmbz_api_proj_resolve_tel( WP_REST_Request $request ) {
    $tel = (string) $request->get_param( 'rmbz_telemovel' );
    if ( $tel === '' && function_exists( 'rmbz_api_authenticate_request' ) ) {
        $auth = rmbz_api_authenticate_request();
        if ( ! is_wp_error( $auth ) ) {
            $tel = (string) $auth;
        }
    }
    return $tel;
}

/**
 * Auth plus a write lock for retained cancelled projects.
 */
function rmbz_api_projects_permission( WP_REST_Request $request ) {
    $auth = rmbz_api_require_auth( $request );
    if ( is_wp_error( $auth ) || $auth !== true ) return $auth;
    if ( strtoupper( $request->get_method() ) === 'GET' ) return true;

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) return true; // POST /projects creates a new one.

    global $wpdb;
    $tel = (string) $request->get_param( 'rmbz_telemovel' );
    $status = $wpdb->get_var( $wpdb->prepare(
        "SELECT production_status FROM {$wpdb->prefix}rmbz_artist_projects
         WHERE id = %d AND artist_telemovel = %s LIMIT 1",
        $project_id, $tel
    ) );
    if ( $status === 'cancelled_no_confirmation' ) {
        return new WP_Error(
            'project_cancelled',
            'Este projecto foi cancelado e já não aceita alterações.',
            [ 'status' => 409 ]
        );
    }
    return true;
}

/**
 * Verifica subscrição activa + perm 'video'.
 *
 * @return WP_REST_Response|null  null = OK, response = erro a propagar
 */
function rmbz_api_proj_require_video_perm( $tel ) {
    if ( ! function_exists( 'rmbz_user_has_active_subscription' ) ||
         ! rmbz_user_has_active_subscription( $tel ) ) {
        return rmbz_api_error(
            'Precisas de uma subscrição VIP activa para candidatar.',
            403,
            'no_active_subscription'
        );
    }

    global $wpdb;
    $extra_raw = $wpdb->get_var( $wpdb->prepare(
        "SELECT extra_data FROM {$wpdb->prefix}rmbz_wallets WHERE user_telemovel = %s",
        $tel
    ) );
    $extra = $extra_raw ? json_decode( $extra_raw, true ) : [];
    if ( ! is_array( $extra ) ) $extra = [];
    $perms = isset( $extra['vip_permissions'] ) && is_array( $extra['vip_permissions'] )
        ? $extra['vip_permissions']
        : [];

    if ( empty( $perms['video'] ) ) {
        return rmbz_api_error(
            'O teu plano não inclui "Ganhar Vídeo". Faz upgrade para usar.',
            403,
            'missing_permission',
            [ 'missing_perm' => 'video' ]
        );
    }

    return null;
}

/**
 * Bloqueia se já existe um projecto pendente (paridade com regra do site).
 */
function rmbz_api_proj_require_no_pending( $tel ) {
    global $wpdb;
    $count = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$wpdb->prefix}rmbz_artist_projects
         WHERE artist_telemovel = %s
           AND production_status = 'pending_review'",
        $tel
    ) );
    if ( $count > 0 ) {
        return rmbz_api_error(
            'Já tens uma candidatura em análise. Aguarda pela avaliação antes de enviar outra.',
            409,
            'already_pending'
        );
    }
    return null;
}

/**
 * Validador de link YouTube.
 */
function rmbz_api_proj_is_valid_youtube( $url ) {
    if ( empty( $url ) ) return true; // opcional
    $u = strtolower( $url );
    return strpos( $u, 'youtube.com' ) !== false || strpos( $u, 'youtu.be' ) !== false;
}

/**
 * Lista de cenários — replica do JS no template (linha 602-606).
 */
function rmbz_api_proj_get_scenarios() {
    return [
        [
            'key'         => 'basic',
            'title'       => 'Básico',
            'subtitle'    => 'Produção Simples',
            'description' => 'Produção simples, 1 locação, sem atores profissionais.',
            'cost_label'  => 'Baixo',
            'requirement' => 'Normal',
            'chance'      => 'Alta (Fácil Aprovação)',
            'color'       => '#2ecc71',
            // [1.6.118.107] Características concretas do nível
            'icon'        => '🎬',
            'tagline'     => 'Perfeito para começar',
            'features'    => [
                [ 'icon' => '📍', 'label' => 'Locações', 'value' => '1 local' ],
                [ 'icon' => '🎥', 'label' => 'Câmaras', 'value' => '1 câmara' ],
                [ 'icon' => '💡', 'label' => 'Iluminação', 'value' => 'Natural / básica' ],
                [ 'icon' => '✂️', 'label' => 'Edição', 'value' => 'Corte + cor simples' ],
                [ 'icon' => '⭐', 'label' => 'Elenco', 'value' => 'Só o artista' ],
                [ 'icon' => '⏱️', 'label' => 'Duração', 'value' => '1 dia de gravação' ],
            ],
            'ideal_for'   => 'Quem está a começar e quer um clip com qualidade sem grande produção.',
            'team_departments' => [ 'direcao', 'camera', 'pos' ],
        ],
        [
            'key'         => 'professional',
            'title'       => 'Profissional',
            'subtitle'    => 'Produção Média',
            'description' => 'Produção média, múltiplas locações, modelos profissionais.',
            'cost_label'  => 'Médio',
            'requirement' => 'Boa Qualidade Musical',
            'chance'      => 'Média',
            'color'       => '#f0b90b',
            'icon'        => '🎥',
            'tagline'     => 'Para quem quer crescer',
            'features'    => [
                [ 'icon' => '📍', 'label' => 'Locações', 'value' => 'Até 3 locais' ],
                [ 'icon' => '🎥', 'label' => 'Câmaras', 'value' => '2 câmaras + estabilizador' ],
                [ 'icon' => '💡', 'label' => 'Iluminação', 'value' => 'Kit profissional' ],
                [ 'icon' => '✂️', 'label' => 'Edição', 'value' => 'Cor avançada + efeitos' ],
                [ 'icon' => '⭐', 'label' => 'Elenco', 'value' => 'Modelos profissionais' ],
                [ 'icon' => '⏱️', 'label' => 'Duração', 'value' => '1 a 2 dias' ],
            ],
            'ideal_for'   => 'Artistas com música de boa qualidade que querem um videoclipe que se destaca.',
            'team_departments' => [ 'direcao', 'camera', 'iluminacao', 'arte', 'pos' ],
        ],
        [
            'key'         => 'cinema',
            'title'       => 'Cinema',
            'subtitle'    => 'Alto Nível',
            'description' => 'Produção cinematográfica completa, atores avançados.',
            'cost_label'  => 'Alto',
            'requirement' => 'Alta Qualidade e Potencial',
            'chance'      => 'Baixa (Exige Hit)',
            'color'       => '#e74c3c',
            'icon'        => '⭐',
            'tagline'     => 'Nível de hit',
            'features'    => [
                [ 'icon' => '📍', 'label' => 'Locações', 'value' => 'Múltiplos + exteriores' ],
                [ 'icon' => '🎥', 'label' => 'Câmaras', 'value' => 'Cinema + drone' ],
                [ 'icon' => '💡', 'label' => 'Iluminação', 'value' => 'Cena completa de luz' ],
                [ 'icon' => '✂️', 'label' => 'Edição', 'value' => 'Color grading + VFX' ],
                [ 'icon' => '⭐', 'label' => 'Elenco', 'value' => 'Atores profissionais' ],
                [ 'icon' => '⏱️', 'label' => 'Duração', 'value' => '2 ou mais dias' ],
            ],
            'ideal_for'   => 'Lançamentos grandes e músicas com potencial de hit, que merecem produção de cinema.',
            'team_departments' => [ 'direcao', 'camera', 'iluminacao', 'grip', 'arte', 'maquilhagem', 'som', 'pos', 'talento' ],
        ],
    ];
}

/**
 * Normaliza projecto para resposta de lista (campos mínimos).
 */
function rmbz_api_proj_format_for_list( $row, $unread_count = 0 ) {
    $extra = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    if ( ! is_array( $extra ) ) $extra = [];
    $is_cancelled = ( (string) ( $row->production_status ?? '' ) === 'cancelled_no_confirmation' );

    // [1.6.118.41] Computar obligatory_cost também na lista, para o app
    // mostrar valores correctos no resumo (não só no detalhe).
    $obligatory_cost = 0.0;
    $pending_decisions_count = 0; // [1.6.118.50] items must_pay=0 + decided=0 (mini-prompt)
    if ( isset( $row->scenario_details ) && ! empty( $row->scenario_details ) ) {
        $items = json_decode( $row->scenario_details, true );
        if ( is_array( $items ) ) {
            foreach ( $items as $it ) {
                $c  = isset( $it['cost'] ) ? (float) $it['cost'] : 0.0;
                $mp = isset( $it['must_pay'] ) ? (int) $it['must_pay'] : 1;
                $aa = ! empty( $it['artist_assumes'] );
                $dc = isset( $it['decided'] ) ? (int) $it['decided'] : 1; // legados = decididos
                if ( $mp === 1 || ! $aa ) $obligatory_cost += $c;
                if ( $mp === 0 && $dc === 0 ) $pending_decisions_count++;
            }
        }
    }
    // Fallback legado
    if ( $obligatory_cost <= 0 ) $obligatory_cost = (float) ( $row->scenario_cost ?? 0 );

    return [
        'id'                => (int) $row->id,
        'project_title'     => (string) ( $row->project_title ?? '' ),
        'scenario_type'     => (string) ( $row->scenario_type ?? '' ),
        'production_status' => (string) ( $row->production_status ?? '' ),
        'scenario_cost'     => (float) ( $row->scenario_cost ?? 0 ),
        'obligatory_cost'   => (float) $obligatory_cost,
        'amount_paid'       => (float) ( $row->amount_paid ?? 0 ),
        'is_paid'           => ! empty( $row->is_paid ),
        'contract_date'     => (string) ( $row->contract_date ?? '' ),
        // [1.6.118.54] Só true se estado não é pending/rejected/correction.
        // Garante consistência com o detail (que esconde proposed_budgets).
        'has_proposed_budgets' => ! empty( $extra['proposed_budgets'] )
                                  && ! in_array(
                                      (string) ( $row->production_status ?? '' ),
                                      [ 'pending_review', 'rejected', 'correction' ],
                                      true
                                  ),
        'has_contract'      => ! empty( $extra['contract_url'] ),
        'contract_accepted' => ! empty( $extra['contract_accepted'] ),
        'final_video_url'   => ! empty( $row->final_video_url ) ? (string) $row->final_video_url : null,
        // [1.6.118.50] Para HomeScreen mostrar banner "items para decidir"
        'pending_decisions_count' => $is_cancelled ? 0 : (int) $pending_decisions_count,
        // [1.6.118.51] Banners HomeScreen — Pitch novo / Vídeo final pronto
        'has_unread_pitch'    => $is_cancelled ? false : rmbz_api_proj_has_unread_pitch( $extra ),
        'has_unseen_final_video' => $is_cancelled ? false : rmbz_api_proj_has_unseen_final_video( $row, $extra ),
        // [1.6.118.55] Banner celebratório de aprovação — aparece após admin
        // tirar projeto de pending_review, some quando artista abre o detalhe.
        'has_unseen_approval' => $is_cancelled ? false : rmbz_api_proj_has_unseen_approval( $row, $extra ),
        // [1.6.118.66] Banner Home — datas de gravação definidas pelo admin e
        // o artista ainda não confirmou nem propôs (estado neutro = pede acção).
        'has_filming_dates_to_confirm' => $is_cancelled ? false : rmbz_api_proj_has_filming_to_confirm( $row, $extra ),
        // [1.6.118.53] Contagem de mensagens não-lidas do admin no chat
        // (calculado agregadamente no handler da lista — evita N+1 query).
        'unread_count'        => $is_cancelled ? 0 : (int) $unread_count,
        // [1.5.398] Pedido de transferência pendente — banner inteligente na Home.
        'transfer_pending'    => ! empty( $extra['transfer_request'] )
            && is_array( $extra['transfer_request'] )
            && ( $extra['transfer_request']['status'] ?? '' ) === 'pending',
        // [1.5.405] Sessão de gravação A DECORRER agora — banner "🎬 ao vivo"
        // na Home; o cronómetro a contar está no detalhe do projeto.
        'filming_active'      => ( function () use ( $extra ) {
            $ss = ( ! empty( $extra['filming_sessions'] ) && is_array( $extra['filming_sessions'] ) ) ? $extra['filming_sessions'] : [];
            foreach ( $ss as $s ) {
                if ( is_array( $s ) && ! empty( $s['started_at'] ) && empty( $s['ended_at'] ) ) return true;
            }
            return false;
        } )(),
        // [1.5.409] Filmagem terminou mas a TAXA DE VISTORIA está por pagar —
        // projeto bloqueado em edição; banner urgente na Home.
        'vistoria_blocked'    => function_exists( 'rmbz_team_vistoria_status' )
            ? (bool) rmbz_team_vistoria_status( $row )['blocked']
            : false,
    ];
}
/**
 * [1.6.118.51] Helper: artista ainda não viu o pitch actualizado.
 * True se pitch existe E (nunca foi visto OU foi actualizado depois da última visualização).
 */
function rmbz_api_proj_has_unread_pitch( $extra ) {
    if ( ! is_array( $extra ) ) return false;
    $pitch = isset( $extra['production_pitch'] ) ? trim( (string) $extra['production_pitch'] ) : '';
    if ( $pitch === '' ) return false;
    $updated = isset( $extra['production_pitch_updated_at'] ) ? (string) $extra['production_pitch_updated_at'] : '';
    if ( $updated === '' ) return false;
    $seen = isset( $extra['production_pitch_seen_at'] ) ? (string) $extra['production_pitch_seen_at'] : '';
    if ( $seen === '' ) return true;
    // Comparar timestamps MySQL — strcmp funciona porque formato é fixo "YYYY-MM-DD HH:MM:SS"
    return strcmp( $updated, $seen ) > 0;
}

/**
 * [1.6.118.51] Helper: vídeo final disponível E ainda não foi visto pelo artista.
 */
function rmbz_api_proj_has_unseen_final_video( $row, $extra ) {
    if ( empty( $row->final_video_url ) ) return false;
    if ( ( $row->production_status ?? '' ) !== 'done' ) return false;
    if ( ! is_array( $extra ) ) return true;
    return empty( $extra['final_video_seen_at'] );
}

/**
 * [1.6.118.55] Helper: projeto foi aprovado (saiu de pending_review) E artista
 * ainda não abriu o detalhe desde então. Aparece em qualquer estado pós-aprovação
 * exceto rejected/correction (esses já têm UI própria).
 */
function rmbz_api_proj_has_unseen_approval( $row, $extra ) {
    if ( ! is_array( $extra ) ) return false;
    if ( empty( $extra['approved_at'] ) ) return false;
    $status = (string) ( $row->production_status ?? '' );
    if ( in_array( $status, [ 'pending_review', 'rejected', 'correction' ], true ) ) return false;
    return empty( $extra['approval_seen_at'] );
}

/**
 * [1.6.118.66] Helper: o admin definiu datas de gravação E o artista ainda
 * não agiu (não confirmou nem propôs). Estado "neutro" = pede acção, por isso
 * aparece banner na Home.
 *
 * Se já confirmou OU já propôs, não há banner (a bola está do lado da equipa).
 */
function rmbz_api_proj_has_filming_to_confirm( $row, $extra ) {
    if ( ! is_array( $extra ) ) return false;
    $status = (string) ( $row->production_status ?? '' );
    // A confirmação só é uma acção pendente antes de as filmagens começarem.
    if ( ! in_array( $status, [ 'pre-production', 'kyc_review' ], true ) ) return false;

    $start = ! empty( $extra['filming_start'] ) ? (string) $extra['filming_start'] : '';
    $end   = ! empty( $extra['filming_end'] )   ? (string) $extra['filming_end']   : '';
    if ( $start === '' && $end === '' ) return false; // admin ainda não definiu

    // Já confirmou → sem banner
    if ( ! empty( $extra['filming_confirmed_by_artist'] ) ) return false;
    // Já propôs contra-datas → sem banner (aguarda equipa)
    if ( ! empty( $extra['filming_proposed_by_artist'] ) ) return false;

    return true;
}

/**
 * Detalhe completo do projecto — inclui budgets propostos, contrato,
 * histórico de chat, etc.
 */
function rmbz_api_proj_format_detail( $row ) {
    $extra = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    if ( ! is_array( $extra ) ) $extra = [];

    $scenario_details = ! empty( $row->scenario_details ) ? json_decode( $row->scenario_details, true ) : null;
    if ( ! is_array( $scenario_details ) ) $scenario_details = null;

    // [1.6.118.34] Normalizar items com novos campos must_pay + artist_assumes
    // (default safe para items legados sem estes campos). Calcular também o
    // valor que o artista TEM mesmo de pagar à 1MZ (obligatory_cost).
    $obligatory_cost = 0.0;
    if ( is_array( $scenario_details ) ) {
        $normalized = [];
        foreach ( $scenario_details as $it ) {
            $cost     = isset( $it['cost'] ) ? (float) $it['cost'] : 0.0;
            $must_pay = isset( $it['must_pay'] ) ? (int) $it['must_pay'] : 1; // default 1
            $assumes  = ! empty( $it['artist_assumes'] ) ? 1 : 0;
            // Coerência defensiva
            if ( $must_pay === 1 ) $assumes = 0;

            $normalized[] = [
                'cat'             => isset( $it['cat'] ) ? (string) $it['cat'] : '',
                'desc'            => isset( $it['desc'] ) ? (string) $it['desc'] : '',
                'cost'            => $cost,
                'checked'         => ! empty( $it['checked'] ) ? 1 : 0,
                'must_pay'        => $must_pay,
                'artist_assumes'  => $assumes,
                // [1.6.118.45] decided: 1 = artista já viu e escolheu, 0 = item adicionado
                // pelo admin pós-contrato (precisa de mini-prompt no app). Default 1
                // para items legados (back-compat).
                'decided'         => isset( $it['decided'] ) ? (int) $it['decided'] : 1,
            ];

            // Conta para obligatory_cost se: obrigatório OU (negociável E artista NÃO assumiu)
            if ( $must_pay === 1 || $assumes === 0 ) {
                $obligatory_cost += $cost;
            }
        }
        $scenario_details = $normalized;
    }

    // Budgets propostos (se ainda não foi escolhida localização)
    // [1.6.118.54] Esconder em estados não-aprovados (pending_review, rejected,
    // correction). Antes, qualquer ajuste do admin durante a avaliação ficava
    // visível ao artista — agora só aparecem após aprovação.
    $proposed_budgets = [];
    $current_status = (string) ( $row->production_status ?? '' );
    $budgets_visible = ! in_array( $current_status, [ 'pending_review', 'rejected', 'correction' ], true );
    if ( $budgets_visible
         && empty( $extra['budget_accepted_location'] )
         && ! empty( $extra['proposed_budgets'] ) ) {
        foreach ( $extra['proposed_budgets'] as $loc_name => $data ) {
            $items = isset( $data['items'] ) && is_array( $data['items'] ) ? $data['items'] : [];
            $clean_items = [];
            foreach ( $items as $it ) {
                $clean_items[] = [
                    'category' => isset( $it['cat'] ) ? (string) $it['cat'] : '',
                    'description' => isset( $it['desc'] ) ? (string) $it['desc'] : '',
                    'cost' => isset( $it['cost'] ) ? (float) $it['cost'] : 0,
                    // [1.5.409] O app precisa de saber quais itens são
                    // NEGOCIÁVEIS para perguntar "cobres tu ou a 1MZ?"
                    // logo na escolha da localização.
                    'must_pay' => isset( $it['must_pay'] ) ? (int) $it['must_pay'] : 1,
                ];
            }
            $proposed_budgets[] = [
                'location' => (string) $loc_name,
                'items'    => $clean_items,
                'total'    => isset( $data['total'] ) ? (float) $data['total'] : 0,
            ];
        }
    }

    // Contrato
    $contract = null;
    if ( ! empty( $extra['contract_url'] ) ) {
        $contract = [
            'url'                    => (string) $extra['contract_url'],
            'accepted'               => ! empty( $extra['contract_accepted'] ),
            'accepted_date'          => ! empty( $extra['contract_accepted_date'] ) ? (string) $extra['contract_accepted_date'] : null,
            'rejected'               => ! empty( $extra['contract_rejected'] ),
            // [1.6.27.2] motivo dado pelo artista quando rejeitou
            'rejected_reason'        => ! empty( $extra['contract_rejected_reason'] ) ? (string) $extra['contract_rejected_reason'] : null,
            'kyc_status'             => ! empty( $extra['kyc_status'] ) ? (string) $extra['kyc_status'] : null,
            // [1.6.27.2] motivo da rejeição do KYC pela equipa de segurança
            'kyc_rejection_reason'   => ! empty( $extra['kyc_rejection_reason'] ) ? (string) $extra['kyc_rejection_reason'] : null,
            // [1.6.27.2] nome real (do BI) que o artista forneceu ao aceitar contrato
            'real_name'              => ! empty( $extra['contract_real_name'] ) ? (string) $extra['contract_real_name'] : null,
            // [1.6.27.2] forma de pagamento escolhida: 'pay_producer' ou 'assume_costs'
            'payment_choice'         => ! empty( $extra['payment_choice'] ) ? (string) $extra['payment_choice'] : null,
            // [1.6.27.2] se admin permitiu opção "assumir custos" neste projecto
            'allow_self_fund'        => ! empty( $extra['allow_self_fund'] ),
            'sent_date'              => ! empty( $extra['contract_sent_date'] ) ? (string) $extra['contract_sent_date'] : null,
            // ATENÇÃO: deadline é unix timestamp em segundos (não ISO).
            // O app converte para Long e calcula (deadline - now).
            'deadline'               => ! empty( $extra['contract_deadline'] ) ? (string) $extra['contract_deadline'] : null,
        ];
    }

    // Chat (artist_feedback é uma string com entradas separadas por \n\n)
    $chat = [];
    if ( ! empty( $row->artist_feedback ) ) {
        $entries = preg_split( '/\n\n+/', (string) $row->artist_feedback );
        foreach ( $entries as $e ) {
            $e = trim( $e );
            if ( $e === '' ) continue;
            // Detectar autor: '<strong>Artista:</strong>' ou '<strong>Equipa:</strong>'
            $is_artist = ( strpos( $e, '<strong>Artista:</strong>' ) !== false );
            // Extrair timestamp [dd/mm/yyyy HH:MM]
            $timestamp = null;
            if ( preg_match( '/^\[([^\]]+)\]/', $e, $m ) ) {
                $timestamp = $m[1];
            }
            // Texto limpo
            $text = preg_replace( '/^\[[^\]]+\]\s*<strong>[^<]+<\/strong>\s*/', '', $e );
            $chat[] = [
                'is_artist' => $is_artist,
                'timestamp' => $timestamp,
                'text'      => trim( wp_strip_all_tags( $text ) ),
            ];
        }
    }

    return [
        'id'                 => (int) $row->id,
        'project_title'      => (string) ( $row->project_title ?? '' ),
        'music_producer'     => (string) ( $row->music_producer ?? '' ),
        'project_pitch'      => (string) ( $row->project_pitch ?? '' ),
        'demo_mp3_url'       => (string) ( $row->demo_mp3_url ?? '' ),
        'project_link_opt'   => ! empty( $row->project_link_opt ) ? (string) $row->project_link_opt : null,
        'scenario_type'      => (string) ( $row->scenario_type ?? '' ),
        'scenario_cost'      => (float) ( $row->scenario_cost ?? 0 ),
        'scenario_details'   => $scenario_details,
        // [1.6.118.34] Total que o artista efectivamente tem de pagar à 1MZ
        // (items obrigatórios + items negociáveis onde NÃO assumiu).
        // Para items legados sem must_pay = igual a scenario_cost.
        'obligatory_cost'    => (float) $obligatory_cost,
        'amount_paid'        => (float) ( $row->amount_paid ?? 0 ),
        'is_paid'            => ! empty( $row->is_paid ),
        'production_status'  => (string) ( $row->production_status ?? '' ),
        'final_video_url'    => ! empty( $row->final_video_url ) ? (string) $row->final_video_url : null,
        'youtube_url'        => ! empty( $row->youtube_url ) ? (string) $row->youtube_url : null,
        'facebook_url'       => ! empty( $row->facebook_url ) ? (string) $row->facebook_url : null,
        'release_date'       => ! empty( $row->release_date ) ? (string) $row->release_date : null,
        'contract_date'      => (string) ( $row->contract_date ?? '' ),
        'rejection_reason'   => ! empty( $extra['admin_rejection_reason'] )
                                  ? (string) $extra['admin_rejection_reason']
                                  : ( ! empty( $extra['cancelled_reason'] ) ? (string) $extra['cancelled_reason'] : null ),
        'budget_accepted_location' => ! empty( $extra['budget_accepted_location'] ) ? (string) $extra['budget_accepted_location'] : null,
        // [1.6.27.2] Guião do videoclipe — admin pode enviar URL ao PDF
        // ou texto inline. Site mostra ambos quando presentes.
        'script_url'         => ! empty( $extra['script_url'] ) ? (string) $extra['script_url'] : null,
        'script_content'     => ! empty( $row->script_content ) ? (string) $row->script_content : null,
        // [1.6.118.30] Pitch da Produção — visão criativa da equipa para o
        // artista. Só exposto a partir de pre-production (artista vê após
        // aprovação). Antes disso esconde para evitar leak de drafts.
        'production_pitch'   => ( ! empty( $extra['production_pitch'] )
                                  && ! in_array( (string) $row->production_status, [ 'pending_review', 'rejected', 'correction' ], true ) )
                                  ? (string) $extra['production_pitch']
                                  : null,
        'production_pitch_updated_at' => ! empty( $extra['production_pitch_updated_at'] )
                                          ? (string) $extra['production_pitch_updated_at']
                                          : null,
        // [1.6.118.64] Datas de gravação (intervalo). Só expostas em estados
        // aprovados (não faz sentido marcar datas antes de aprovar). null se
        // o admin ainda não definiu.
        'filming_dates'      => rmbz_api_proj_format_filming_dates( $extra, (string) $row->production_status ),
        // [1.6.118.88] Equipa do videoclipe — pessoas atribuídas pelo admin.
        // Só exposta em estados aprovados (igual ao filming). Contacto dos
        // membros é omitido pelo helper (só admin). Lista vazia se nada.
        'team'               => in_array( (string) $row->production_status, [ 'pending_review', 'rejected', 'correction' ], true )
                                  ? []
                                  : ( function_exists( 'rmbz_team_for_project' ) ? rmbz_team_for_project( $extra ) : [] ),
        // [1.6.118.99] Estado/diagnóstico da equipa para o artista:
        //   ready          → equipa essencial completa
        //   in_preparation → ainda faltam cargos (a 1MZ está a montar)
        //   inspection_fee → taxa de vistoria por pessoa externa que o artista pediu
        'team_status'        => function_exists( 'rmbz_api_proj_team_status' )
                                  ? rmbz_api_proj_team_status( $extra, (string) $row->production_status )
                                  : null,
        'proposed_budgets'   => $proposed_budgets,
        'contract'           => $contract,
        'chat'               => $chat,
        'extra_artist_name'  => ! empty( $extra['artist_name'] ) ? (string) $extra['artist_name'] : null,
    ];
}

/**
 * [1.6.118.64] Formata o bloco de datas de gravação para o detalhe.
 *
 * Devolve null se não houver datas definidas OU se o projecto ainda está em
 * estado não-aprovado (pending_review/rejected/correction). Caso contrário:
 *
 *   {
 *     "start": "2026-06-15",
 *     "end": "2026-06-17",
 *     "set_by": "admin",
 *     "confirmed_by_artist": true|false,
 *     "proposed_by_artist": { "start": "...", "end": "...", "at": "..." } | null
 *   }
 */
function rmbz_api_proj_format_filming_dates( $extra, $status ) {
    if ( in_array( $status, [ 'pending_review', 'rejected', 'correction' ], true ) ) {
        return null;
    }
    $start = ! empty( $extra['filming_start'] ) ? (string) $extra['filming_start'] : '';
    $end   = ! empty( $extra['filming_end'] )   ? (string) $extra['filming_end']   : '';
    if ( $start === '' && $end === '' ) {
        return null;
    }

    $proposed = null;
    if ( ! empty( $extra['filming_proposed_by_artist'] ) && is_array( $extra['filming_proposed_by_artist'] ) ) {
        $p = $extra['filming_proposed_by_artist'];
        $proposed = [
            'start' => isset( $p['start'] ) ? (string) $p['start'] : null,
            'end'   => isset( $p['end'] )   ? (string) $p['end']   : null,
            'at'    => isset( $p['at'] )    ? (string) $p['at']    : null,
        ];
    }

    return [
        'start'               => $start !== '' ? $start : null,
        'end'                 => $end !== '' ? $end : null,
        'set_by'              => ! empty( $extra['filming_set_by'] ) ? (string) $extra['filming_set_by'] : 'admin',
        'confirmed_by_artist' => ! empty( $extra['filming_confirmed_by_artist'] ),
        'proposed_by_artist'  => $proposed,
    ];
}

/**
 * [1.6.118.99] Estado/diagnóstico da equipa para o artista.
 *
 * Devolve null em estados não-aprovados (não há nada a mostrar ainda).
 * Caso contrário um array com:
 *   - in_preparation (bool): faltam cargos essenciais (1MZ a montar equipa)
 *   - missing_labels (array): nomes dos departamentos em falta
 *   - inspection_fee_due (bool): há pessoa externa que o ARTISTA pediu
 *   - inspection_fee (float): valor da taxa de vistoria
 *   - inspection_names (array): nomes das pessoas externas pedidas pelo artista
 *
 * NÃO expõe pessoas externas definidas pela 1MZ (isso é decisão interna).
 */
function rmbz_api_proj_team_status( $extra, $status ) {
    if ( in_array( $status, [ 'pending_review', 'rejected', 'correction' ], true ) ) {
        return null;
    }
    if ( ! function_exists( 'rmbz_team_validate_project' ) ) {
        return null;
    }
    $diag = rmbz_team_validate_project( $extra );

    // Só faz sentido mostrar algo se já há equipa atribuída OU faltam cargos.
    $has_team = $diag['member_count'] > 0;
    if ( ! $has_team && empty( $diag['missing'] ) ) {
        return null;
    }

    $fee_due = ! empty( $diag['external_by_artist'] );

    // [1.6.118.102] Total = soma da taxa do departamento de cada externo
    // pedido pelo artista (taxas podem variar por departamento).
    $fee_total = 0.0;
    if ( $fee_due && function_exists( 'rmbz_team_inspection_fee' ) ) {
        foreach ( $diag['external_by_artist'] as $m ) {
            $fee_total += rmbz_team_inspection_fee( $m->department ?? '' );
        }
    }

    return [
        'in_preparation'     => ! $diag['ok'],
        'missing_labels'     => array_map( fn( $m ) => $m['icon'] . ' ' . $m['label'], $diag['missing'] ),
        'inspection_fee_due' => $fee_due,
        'inspection_fee'     => $fee_total, // total somado (não por pessoa)
        'inspection_names'   => array_map( fn( $m ) => (string) $m->name, $diag['external_by_artist'] ),
    ];
}


/* ============================================================================
 * GET /projects
 * ============================================================================ */

function rmbz_api_handle_projects_list( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    global $wpdb;
    $rows = $wpdb->get_results( $wpdb->prepare(
        "SELECT id, project_title, scenario_type, production_status,
                scenario_cost, scenario_details, amount_paid, is_paid, contract_date,
                final_video_url, extra_data
         FROM {$wpdb->prefix}rmbz_artist_projects
         WHERE artist_telemovel = %s
         ORDER BY contract_date DESC, id DESC
         LIMIT 200",
        $tel
    ) );

    // [1.6.118.53] Contagem agregada de mensagens não-lidas por projeto
    // (1 query em vez de N). Map projectId → count. Conta apenas mensagens
    // do admin com read_by_artist=0 (system messages e do próprio artista
    // não contam).
    $unread_map = [];
    if ( ! empty( $rows ) ) {
        $project_ids = array_map( fn( $r ) => (int) $r->id, $rows );
        $ids_csv = implode( ',', $project_ids );
        $chat_table = $wpdb->prefix . 'rmbz_project_chat_messages';
        // Verifica se tabela existe antes (instalações antigas podem não ter)
        $exists = $wpdb->get_var( "SHOW TABLES LIKE '{$chat_table}'" );
        if ( $exists ) {
            $unread_rows = $wpdb->get_results(
                "SELECT project_id, COUNT(*) AS cnt
                 FROM {$chat_table}
                 WHERE project_id IN ({$ids_csv})
                   AND author_type = 'admin'
                   AND read_by_artist = 0
                   AND is_deleted = 0
                 GROUP BY project_id"
            );
            if ( $unread_rows ) {
                foreach ( $unread_rows as $u ) {
                    $unread_map[ (int) $u->project_id ] = (int) $u->cnt;
                }
            }
        }
    }

    $projects = [];
    $has_pending = false;
    if ( $rows ) {
        foreach ( $rows as $r ) {
            if ( ( $r->production_status ?? '' ) === 'cancelled_no_confirmation'
                 && function_exists( 'rmbz_filming_project_hidden_from_artist' )
                 && rmbz_filming_project_hidden_from_artist( $r ) ) {
                continue;
            }
            $unread_count = $unread_map[ (int) $r->id ] ?? 0;
            $projects[] = rmbz_api_proj_format_for_list( $r, $unread_count );
            if ( $r->production_status === 'pending_review' ) $has_pending = true;
        }
    }

    // [1.6.332] Multa por atraso (projeto em curso + subscrição expirada).
    $overdue_fine = function_exists( 'rmbz_project_overdue_info' )
        ? rmbz_project_overdue_info( $tel )
        : [ 'overdue' => false ];

    // [1.5.401] Convites de participação (feat) PENDENTES para este artista
    // (ele é o participante noutro projeto). Banner no Home + diálogo de termos.
    $feat_invites = function_exists( 'rmbz_feat_pending_for' )
        ? rmbz_feat_pending_for( $tel )
        : [];

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'projects'     => $projects,
        'total'        => count( $projects ),
        'has_pending'  => $has_pending,
        'overdue_fine' => $overdue_fine,
        'feat_invites' => $feat_invites,
    ] );
}


/* ============================================================================
 * GET /projects/options
 * ============================================================================ */

function rmbz_api_handle_projects_options( WP_REST_Request $request ) {
    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'scenarios'    => rmbz_api_proj_get_scenarios(),
    ] );
}


/* ============================================================================
 * GET /projects/{id}
 * ============================================================================ */

function rmbz_api_handle_project_detail( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) {
        return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );
    }

    global $wpdb;
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}rmbz_artist_projects
         WHERE id = %d AND artist_telemovel = %s",
        $project_id, $tel
    ) );

    if ( ! $row ) {
        return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );
    }
    if ( ( $row->production_status ?? '' ) === 'cancelled_no_confirmation'
         && function_exists( 'rmbz_filming_project_hidden_from_artist' )
         && rmbz_filming_project_hidden_from_artist( $row ) ) {
        return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );
    }

    // [1.6.118.77] Bloqueio: abrir o detalhe de um projecto de vídeo exige
    // subscrição VIP activa. Projectos legados de quando o artista tinha VIP
    // ficam inacessíveis até renovar. O código 'vip_required' permite à app
    // mostrar o ecrã "Precisas de VIP" + botão de subscrever.
    if ( ! function_exists( 'rmbz_user_has_active_subscription' )
         || ! rmbz_user_has_active_subscription( $tel ) ) {
        return rmbz_api_error(
            'Precisas de uma subscrição VIP activa para abrir os teus projectos de vídeo.',
            403,
            'vip_required'
        );
    }

    // [1.6.118.51] Marcar pitch e vídeo final como vistos pelo artista
    // (apaga os banners do Home). Atualização é silenciosa — não falha
    // o pedido se a escrita falhar.
    $extra_for_seen = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    if ( ! is_array( $extra_for_seen ) ) $extra_for_seen = [];
    $now_utc = current_time( 'mysql', 1 );
    $dirty = false;

    // Pitch visto: só marca se há pitch novo (com updated_at > seen_at)
    if ( ! empty( $extra_for_seen['production_pitch'] )
         && ! empty( $extra_for_seen['production_pitch_updated_at'] ) ) {
        $upd = (string) $extra_for_seen['production_pitch_updated_at'];
        $seen = isset( $extra_for_seen['production_pitch_seen_at'] ) ? (string) $extra_for_seen['production_pitch_seen_at'] : '';
        if ( $seen === '' || strcmp( $upd, $seen ) > 0 ) {
            $extra_for_seen['production_pitch_seen_at'] = $now_utc;
            $dirty = true;
        }
    }

    // Vídeo final visto: só marca se estado é done + vídeo disponível
    if ( ( $row->production_status ?? '' ) === 'done'
         && ! empty( $row->final_video_url )
         && empty( $extra_for_seen['final_video_seen_at'] ) ) {
        $extra_for_seen['final_video_seen_at'] = $now_utc;
        $dirty = true;
    }

    // [1.6.118.55] Aprovação vista: marca quando artista abre detalhe pela
    // primeira vez após admin aprovar. Estados rejected/correction não geram
    // banner de aprovação, logo aqui também ignoramos.
    $cur_status = (string) ( $row->production_status ?? '' );
    if ( ! empty( $extra_for_seen['approved_at'] )
         && empty( $extra_for_seen['approval_seen_at'] )
         && ! in_array( $cur_status, [ 'pending_review', 'rejected', 'correction' ], true ) ) {
        $extra_for_seen['approval_seen_at'] = $now_utc;
        $dirty = true;
    }

    if ( $dirty ) {
        $wpdb->update(
            $wpdb->prefix . 'rmbz_artist_projects',
            [ 'extra_data' => wp_json_encode( $extra_for_seen ) ],
            [ 'id' => $project_id ]
        );
        // Reflectir no $row em memória para o format_detail abaixo
        $row->extra_data = wp_json_encode( $extra_for_seen );
    }

    // [1.5.410] SELF-HEAL do is_paid: se itens foram acrescentados depois do
    // pagamento (ex.: taxa de vistoria) o obrigatório subiu — um projeto
    // marcado "pago" com amount_paid abaixo do obrigatório é corrigido aqui
    // (o apply antigo não repunha o is_paid; linhas legadas ficavam "PAGO
    // · Restante 0" no Controlo Financeiro mesmo com vistoria por pagar).
    if ( (int) ( $row->is_paid ?? 0 ) === 1 && ! empty( $row->scenario_details ) ) {
        $sh_items = json_decode( (string) $row->scenario_details, true );
        if ( is_array( $sh_items ) ) {
            $sh_oblig = 0.0;
            foreach ( $sh_items as $sh_it ) {
                if ( ! is_array( $sh_it ) ) continue;
                $sh_mp = isset( $sh_it['must_pay'] ) ? (int) $sh_it['must_pay'] : 1;
                $sh_aa = ! empty( $sh_it['artist_assumes'] );
                if ( $sh_mp === 1 || ! $sh_aa ) $sh_oblig += isset( $sh_it['cost'] ) ? (float) $sh_it['cost'] : 0.0;
            }
            if ( $sh_oblig > (float) ( $row->amount_paid ?? 0 ) + 0.009 ) {
                $wpdb->update( $wpdb->prefix . 'rmbz_artist_projects', [ 'is_paid' => 0 ], [ 'id' => (int) $row->id ], [ '%d' ], [ '%d' ] );
                $row->is_paid = 0;
            }
        }
    }

    // [1.6.381] Incluir a multa por atraso/pendente no detalhe — a app usa para
    // TRANCAR o projeto enquanto houver dívida (mesmo com VIP já ativo).
    $proj_detail = (array) rmbz_api_proj_format_detail( $row );
    // [1.5.398] rmbz_project_overdue_info é POR ARTISTA — só anexamos a multa
    // quando pertence a ESTE projeto, senão a dívida de um vídeo aparecia (e
    // trancava) os outros projetos do mesmo artista no app.
    $ovf = function_exists( 'rmbz_project_overdue_info' )
        ? rmbz_project_overdue_info( $tel )
        : [ 'overdue' => false ];
    if ( ! empty( $ovf['project_id'] ) && (int) $ovf['project_id'] !== (int) $row->id ) {
        $ovf = [ 'overdue' => false ];
    }
    $proj_detail['overdue_fine'] = $ovf;

    // [1.5.398] Pedido de transferência pendente/aceite — o app mostra o banner
    // e permite aceitar (com termos) ou recusar.
    $tr_extra = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    $tr = is_array( $tr_extra ) && ! empty( $tr_extra['transfer_request'] ) && is_array( $tr_extra['transfer_request'] )
        ? $tr_extra['transfer_request'] : null;
    $proj_detail['transfer_request'] = $tr ? [
        'status'         => (string) ( $tr['status'] ?? '' ),
        'reason'         => (string) ( $tr['reason'] ?? '' ),
        'requested_at'   => (string) ( $tr['requested_at'] ?? '' ),
        // [1.5.398] Sugestão do artista (nome + número do verdadeiro dono).
        'suggested_name' => (string) ( $tr['suggested_name'] ?? '' ),
        'suggested_tel'  => (string) ( $tr['suggested_tel'] ?? '' ),
    ] : null;

    // [1.5.405] GRAVAÇÃO AO VIVO para o ARTISTA: sessão a decorrer (cronómetro
    // a contar na app/site) + progresso ("falta X% — continua dia Y").
    $proj_detail['filming_live'] = rmbz_api_proj_filming_live_block( $tr_extra );

    // [1.5.409] TAXA DE VISTORIA: quando a filmagem acaba e a taxa está por
    // pagar, o projeto fica BLOQUEADO em edição — o app mostra o motivo e o
    // valor em falta (paga-se pelo fluxo normal de pagamento do projeto).
    $proj_detail['vistoria'] = function_exists( 'rmbz_team_vistoria_status' )
        ? rmbz_team_vistoria_status( $row )
        : [ 'total' => 0, 'left' => 0, 'blocked' => false ];

    // [1.5.411] ENTREGA AO EDITOR EXTERNO: depois da taxa paga, o artista vê
    // os ficheiros preparados (nº/GB/qualidade) e depois a entrega confirmada
    // ("o editor já levou tudo — a 1MZ só aguarda a edição").
    $proj_detail['editor_delivery'] = null;
    if ( function_exists( 'rmbz_editor_delivery_get' ) && is_array( $tr_extra ) ) {
        $ed = rmbz_editor_delivery_get( $tr_extra );
        if ( $ed['files'] > 0 ) {
            $proj_detail['editor_delivery'] = [
                'files'        => (int) $ed['files'],
                'size_gb'      => (float) $ed['size_gb'],
                'quality'      => (string) $ed['quality'],
                'delivered'    => ! empty( $ed['delivered'] ),
                'delivered_at' => (string) $ed['delivered_at'],
            ];
        }
    }

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'project'      => $proj_detail,
    ] );
}

/**
 * [1.5.405] Bloco filming_live visto pelo ARTISTA (dono do projeto).
 */
function rmbz_api_proj_filming_live_block( $extra ) {
    if ( ! is_array( $extra ) ) $extra = [];
    $sessions = ( ! empty( $extra['filming_sessions'] ) && is_array( $extra['filming_sessions'] ) ) ? $extra['filming_sessions'] : [];
    $active = null;
    $days_done = 0;
    $last_next = '';
    foreach ( $sessions as $s ) {
        if ( ! is_array( $s ) ) continue;
        if ( empty( $s['ended_at'] ) ) {
            $active = [
                'day'        => (int) ( $s['day'] ?? 0 ),
                'started_at' => (string) ( $s['started_at'] ?? '' ),
                'started_ts' => (int) ( $s['started_ts'] ?? 0 ),
            ];
        } else {
            $days_done++;
            if ( ! empty( $s['next_date'] ) ) $last_next = (string) $s['next_date'];
        }
    }
    return [
        'active'       => $active !== null,
        'day'          => $active['day'] ?? 0,
        'started_at'   => $active['started_at'] ?? '',
        'started_ts'   => $active['started_ts'] ?? 0,
        'server_now'   => (int) current_time( 'timestamp', true ),
        'days_done'    => $days_done,
        'percent_left' => isset( $extra['filming_progress']['percent_left'] ) ? (int) $extra['filming_progress']['percent_left'] : -1,
        'next_date'    => $last_next,
    ];
}

/* ============================================================================
 * GET /projects/feat-check — [1.5.401] "Artista válido" no formulário
 * ============================================================================ */

function rmbz_api_handle_feat_check( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );
    if ( ! function_exists( 'rmbz_feat_check_account' ) ) {
        return rmbz_api_error( 'Verificação indisponível.', 500, 'unavailable' );
    }
    $check = rmbz_feat_check_account( (string) $request->get_param( 'tel' ) );
    $is_self = $check['exists'] && $check['tel'] === (string) $tel;
    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'exists'       => $check['exists'] && ! $is_self,
        'self'         => $is_self,
        'name'         => $is_self ? '' : $check['name'],
    ] );
}

/* ============================================================================
 * POST /projects/feat-respond — [1.5.401] Participante aceita/recusa o convite
 * ============================================================================ */

function rmbz_api_handle_feat_respond( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    if ( ! function_exists( 'rmbz_feat_respond_core' ) ) {
        return rmbz_api_error( 'Função de participação indisponível.', 500, 'unavailable' );
    }

    $project_id = (int) $request->get_param( 'project_id' );
    $decision   = ( (string) $request->get_param( 'decision' ) === 'accept' ) ? 'accept' : 'reject';
    $terms_raw  = $request->get_param( 'terms_accepted' );
    $terms_ok   = ( $terms_raw === true || $terms_raw === 1 || $terms_raw === '1' || $terms_raw === 'true' );

    $res = rmbz_feat_respond_core( $tel, $project_id, $decision, $terms_ok );
    if ( empty( $res['success'] ) ) {
        $http = ( ( $res['code'] ?? '' ) === 'terms_required' ) ? 400 : 409;
        return rmbz_api_error( $res['message'] ?? 'Não foi possível responder ao convite.', $http, $res['code'] ?? 'feat_failed' );
    }
    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'code'         => (string) ( $res['code'] ?? '' ),
        'message'      => (string) ( $res['message'] ?? '' ),
    ] );
}

/* ============================================================================
 * POST /projects/transfer-respond — [1.5.398] Artista aceita/recusa transferência
 * ============================================================================ */

function rmbz_api_handle_project_transfer_respond( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    if ( ! function_exists( 'rmbz_project_transfer_respond_core' ) ) {
        return rmbz_api_error( 'Função de transferência indisponível.', 500, 'unavailable' );
    }

    $project_id = (int) $request->get_param( 'project_id' );
    $decision   = ( (string) $request->get_param( 'decision' ) === 'accept' ) ? 'accept' : 'reject';
    $terms_raw  = $request->get_param( 'terms_accepted' );
    $terms_ok   = ( $terms_raw === true || $terms_raw === 1 || $terms_raw === '1' || $terms_raw === 'true' );
    $sug_name   = (string) $request->get_param( 'suggested_name' );
    $sug_tel    = (string) $request->get_param( 'suggested_tel' );

    $res = rmbz_project_transfer_respond_core( $tel, $project_id, $decision, $terms_ok, $sug_name, $sug_tel );
    if ( empty( $res['success'] ) ) {
        $http = ( ( $res['code'] ?? '' ) === 'terms_required' ) ? 400 : 409;
        return rmbz_api_error( $res['message'] ?? 'Não foi possível responder ao pedido.', $http, $res['code'] ?? 'transfer_failed' );
    }
    return rmbz_api_ok( [
        'success'  => true,
        'decision' => $decision,
        'message'  => (string) $res['message'],
    ] );
}


/* ============================================================================
 * POST /projects
 * ============================================================================ */

function rmbz_api_handle_project_create( WP_REST_Request $request ) {
    // [1.6.47] Bloqueio global de candidaturas a videoclipe
    if ( function_exists( 'rmbz_is_globally_blocked' ) && rmbz_is_globally_blocked( 'video' ) ) {
        return rmbz_api_error(
            'As candidaturas a videoclipe estão temporariamente suspensas pela administração. Tenta de novo mais tarde.',
            403,
            'video_globally_blocked'
        );
    }

    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    // Permissão
    $perm_err = rmbz_api_proj_require_video_perm( $tel );
    if ( $perm_err ) return $perm_err;

    // Anti-duplicado
    $pending_err = rmbz_api_proj_require_no_pending( $tel );
    if ( $pending_err ) return $pending_err;

    $body = $request->get_json_params();
    if ( ! is_array( $body ) ) $body = [];

    // Campos do form win_video (paridade com linhas 396-441 do template)
    $artist_name      = trim( (string) ( $body['artist_name']       ?? '' ) );
    $project_title    = trim( (string) ( $body['project_title']     ?? '' ) );
    $music_producer   = trim( (string) ( $body['music_producer']    ?? '' ) );
    $project_pitch    = trim( (string) ( $body['project_pitch']     ?? '' ) );
    $project_link_opt = trim( (string) ( $body['project_link_opt']  ?? '' ) );
    $demo_url         = trim( (string) ( $body['demo_mp3_url']      ?? '' ) );
    $scenario_type    = trim( (string) ( $body['scenario_type']     ?? '' ) );

    $decl_author = ! empty( $body['decl_author'] );
    $decl_no_video = ! empty( $body['decl_no_video'] );
    $decl_terms  = ! empty( $body['decl_terms'] );

    // [1.5.398] Participação (feat) — APENAS tracking/registo; não altera
    // pagamentos, contrato nem produção.
    $feat_enabled     = ! empty( $body['feat_enabled'] );
    $feat_name        = trim( (string) ( $body['feat_name'] ?? '' ) );
    $feat_has_account = ! empty( $body['feat_has_account'] );
    $feat_tel         = preg_replace( '/[^0-9+]/', '', (string) ( $body['feat_tel'] ?? '' ) );

    // Validações (mesma ordem do form site)
    if ( $artist_name === '' )    return rmbz_api_error( 'Nome do artista obrigatório.', 422, 'missing_artist' );
    if ( $project_title === '' )  return rmbz_api_error( 'Título da música obrigatório.', 422, 'missing_title' );
    if ( $music_producer === '' ) return rmbz_api_error( 'Nome do produtor obrigatório.', 422, 'missing_producer' );
    if ( $demo_url === '' )       return rmbz_api_error( 'Demo MP3 obrigatória.', 422, 'missing_demo' );
    if ( ! rmbz_api_proj_is_valid_youtube( $project_link_opt ) ) {
        return rmbz_api_error(
            'O link de referência deve ser do YouTube.',
            422, 'invalid_youtube'
        );
    }

    $valid_scenarios = [ 'basic', 'professional', 'cinema' ];
    if ( ! in_array( $scenario_type, $valid_scenarios, true ) ) {
        return rmbz_api_error( 'Cenário inválido.', 422, 'invalid_scenario' );
    }

    if ( $project_pitch === '' )  return rmbz_api_error( 'Ideia do videoclipe obrigatória.', 422, 'missing_pitch' );
    if ( $feat_enabled && $feat_name === '' ) {
        return rmbz_api_error( 'Indica o nome do participante (feat).', 422, 'missing_feat_name' );
    }
    if ( $feat_enabled && $feat_has_account && strlen( $feat_tel ) < 7 ) {
        return rmbz_api_error( 'Indica o número da conta do participante.', 422, 'missing_feat_tel' );
    }
    // [1.5.401] A conta do participante tem de EXISTIR mesmo (e não ser a do dono).
    if ( $feat_enabled && $feat_has_account && function_exists( 'rmbz_feat_check_account' ) ) {
        $feat_chk = rmbz_feat_check_account( $feat_tel );
        if ( ! $feat_chk['exists'] ) {
            return rmbz_api_error( 'O número da conta do participante não foi encontrado na plataforma.', 422, 'feat_account_not_found' );
        }
        if ( $feat_chk['tel'] === (string) $tel ) {
            return rmbz_api_error( 'O participante não pode ser a tua própria conta.', 422, 'feat_self' );
        }
        $feat_tel = $feat_chk['tel'];
    }
    if ( ! $decl_author )         return rmbz_api_error( 'Tens de confirmar que és o autor.', 422, 'missing_decl_author' );
    if ( ! $decl_no_video )       return rmbz_api_error( 'Tens de confirmar que a música não tem videoclipe oficial.', 422, 'missing_decl_no_video' );
    if ( ! $decl_terms )          return rmbz_api_error( 'Tens de aceitar os termos.', 422, 'missing_decl_terms' );
    if ( function_exists( 'rmbz_filming_is_cancelled_music_blocked' )
         && rmbz_filming_is_cancelled_music_blocked( $tel, $project_title, $demo_url ) ) {
        return rmbz_api_error(
            'Esta música já teve um projecto cancelado por falta de confirmação e não pode ser reenviada.',
            409,
            'cancelled_music_blocked'
        );
    }

    // Guardar artist_name no extra_data (não há coluna própria)
    $extra_data = [
        'artist_name' => sanitize_text_field( $artist_name ),
    ];
    // [1.5.398] Feat (tracking): nome + se tem conta 1MZ (+ número quando tem).
    if ( $feat_enabled ) {
        $extra_data['feat'] = [
            'name'        => sanitize_text_field( $feat_name ),
            'has_account' => (bool) $feat_has_account,
            'tel'         => $feat_has_account ? $feat_tel : '',
        ];
    }

    global $wpdb;
    $inserted = $wpdb->insert(
        $wpdb->prefix . 'rmbz_artist_projects',
        [
            'artist_telemovel'  => $tel,
            'project_title'     => sanitize_text_field( $project_title ),
            'music_producer'    => sanitize_text_field( $music_producer ),
            'project_pitch'     => sanitize_textarea_field( $project_pitch ),
            'demo_mp3_url'      => esc_url_raw( $demo_url ),
            'project_link_opt'  => esc_url_raw( $project_link_opt ),
            'scenario_type'     => sanitize_text_field( $scenario_type ),
            'scenario_cost'     => 0,
            'production_status' => 'pending_review',
            'extra_data'        => wp_json_encode( $extra_data ),
            'contract_date'     => current_time( 'mysql' ),
        ]
    );

    if ( $inserted === false ) {
        return rmbz_api_error( 'Falha a guardar candidatura.', 500, 'db_error' );
    }

    $new_project_id = (int) $wpdb->insert_id;

    // [1.5.401] Feat com conta 1MZ → convida o participante em todos os
    // canais (ele aceita com termos, ou recusa e o projeto é cancelado).
    if ( function_exists( 'rmbz_feat_invite_create' ) ) {
        rmbz_feat_invite_create( $new_project_id );
    }

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $new_project_id,
        'message'      => 'Candidatura enviada para análise. A equipa 1MZ vai rever em breve.',
    ], 201 );
}


/* ============================================================================
 * POST /projects/{id}/choose-budget
 * ============================================================================ */

function rmbz_api_handle_project_choose_budget( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );

    $body = $request->get_json_params();
    $chosen_loc = trim( (string) ( $body['location'] ?? '' ) );
    if ( $chosen_loc === '' ) return rmbz_api_error( 'Localização obrigatória.', 422, 'missing_location' );

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $project = $wpdb->get_row( $wpdb->prepare(
        "SELECT extra_data FROM $table WHERE id = %d AND artist_telemovel = %s",
        $project_id, $tel
    ) );

    if ( ! $project ) return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );

    $extra = json_decode( $project->extra_data, true );
    if ( ! is_array( $extra ) ) $extra = [];

    if ( ! empty( $extra['budget_accepted_location'] ) ) {
        return rmbz_api_error(
            'Já escolheste uma localização para este projecto.',
            409, 'already_chosen'
        );
    }
    if ( empty( $extra['proposed_budgets'][ $chosen_loc ] ) ) {
        return rmbz_api_error(
            'Localização não disponível neste projecto.',
            422, 'invalid_location'
        );
    }

    $budget_data = $extra['proposed_budgets'][ $chosen_loc ];
    $extra['budget_accepted_location'] = $chosen_loc;
    unset( $extra['proposed_budgets'] );
    unset( $extra['location_choice_set_at'], $extra['location_cancel_warned'] );

    // [1.5.409] DECISÕES POR ITEM feitas logo na escolha da localização:
    // para cada item NEGOCIÁVEL (must_pay=0), o artista diz quem cobre —
    //   item_decisions[idx] = 1 → "eu cubro" (artist_assumes, fora da 1MZ)
    //   item_decisions[idx] = 0 → "a 1MZ trata" (paga à 1MZ)
    // Assim o mini-prompt posterior deixa de ser preciso: escolhe → decide
    // → finaliza, e o projeto atualiza de uma vez.
    $items = is_array( $budget_data['items'] ?? null ) ? array_values( $budget_data['items'] ) : [];
    $decisions = $body['item_decisions'] ?? null;
    if ( is_string( $decisions ) ) $decisions = json_decode( $decisions, true );
    if ( is_array( $decisions ) ) {
        foreach ( $items as $idx => &$it ) {
            if ( ! is_array( $it ) ) continue;
            if ( ! isset( $it['must_pay'] ) || (int) $it['must_pay'] !== 0 ) continue; // só negociáveis
            $dec = $decisions[ $idx ] ?? $decisions[ (string) $idx ] ?? null;
            if ( $dec === null ) continue;
            $it['artist_assumes'] = ( (int) $dec === 1 ) ? 1 : 0;
            $it['decided']        = 1;
        }
        unset( $it );
    }

    $wpdb->update(
        $table,
        [
            'scenario_details' => wp_json_encode( $items ),
            'scenario_cost'    => (float) ( $budget_data['total'] ?? 0 ),
            'extra_data'       => wp_json_encode( $extra ),
        ],
        [ 'id' => $project_id ]
    );

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'location'     => $chosen_loc,
        'total'        => (float) ( $budget_data['total'] ?? 0 ),
        'message'      => "Orçamento de $chosen_loc seleccionado com sucesso.",
    ] );
}


/* ============================================================================
 * POST /projects/{id}/accept-contract
 *
 * Aceita o contrato + faz upload da foto do BI (KYC). Multipart upload
 * (campo `kyc_photo`) + body fields: `real_name`, `payment_choice`.
 * ============================================================================ */

function rmbz_api_handle_project_accept_contract( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );

    // Em multipart, params vêm em $_POST
    $real_name      = isset( $_POST['real_name'] ) ? sanitize_text_field( wp_unslash( $_POST['real_name'] ) ) : '';
    $payment_choice = isset( $_POST['payment_choice'] ) ? sanitize_text_field( wp_unslash( $_POST['payment_choice'] ) ) : 'pay_producer';

    if ( $real_name === '' ) {
        return rmbz_api_error( 'Nome real (no BI) obrigatório.', 422, 'missing_real_name' );
    }
    if ( empty( $_FILES['kyc_photo'] ) ) {
        return rmbz_api_error( 'Foto do BI obrigatória.', 422, 'missing_kyc_photo' );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $project = $wpdb->get_row( $wpdb->prepare(
        "SELECT extra_data, project_title FROM $table WHERE id = %d AND artist_telemovel = %s",
        $project_id, $tel
    ) );

    if ( ! $project ) return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );

    $extra = json_decode( $project->extra_data, true );
    if ( ! is_array( $extra ) ) $extra = [];

    if ( empty( $extra['contract_url'] ) ) {
        return rmbz_api_error(
            'Este projecto ainda não tem contrato disponível.',
            409, 'no_contract'
        );
    }
    if ( ! empty( $extra['contract_accepted'] ) ) {
        return rmbz_api_error( 'Contrato já foi aceite anteriormente.', 409, 'already_accepted' );
    }

    // Upload do BI (reutilizar rmbz_handle_proof_upload)
    $kyc_photo_url = '';
    if ( function_exists( 'rmbz_handle_proof_upload' ) ) {
        $kyc_photo_url = rmbz_handle_proof_upload( 'kyc_photo' );
    }
    if ( ! $kyc_photo_url ) {
        return rmbz_api_error(
            'Falha ao guardar foto do BI. Envia uma imagem válida (JPG/PNG, máx 10MB).',
            500, 'upload_failed'
        );
    }

    $extra['contract_accepted']      = true;
    $extra['contract_rejected']      = false;
    $extra['contract_accepted_date'] = current_time( 'mysql' );
    $extra['contract_real_name']     = $real_name;
    $extra['contract_kyc_photo']     = $kyc_photo_url;
    $extra['kyc_status']             = 'pending_admin_review';
    $extra['payment_choice']         = $payment_choice;

    /* ------------------------------------------------------------------------
     * [1.6.118.53] Decisões per-item do artista (modo misto via app)
     *
     * Paridade exacta com onemz_accept_contract_action() do form-handler.php
     * (linhas 1316-1354). Aceita `item_decisions` como JSON-string no multipart
     * com formato { "0": 1, "2": 0, ... } onde a key é o idx em scenario_details
     * e o valor é 0 (1MZ paga) ou 1 (artista assume).
     *
     * Só items must_pay=0 (negociáveis) recebem a decisão do artista. Items
     * must_pay=1 (obrigatórios) são forçados a artist_assumes=0 por coerência.
     * Todos os items ficam decided=1 (artista viu a UI de escolha).
     *
     * Se o campo não vier no request, comporta-se como antes (back-compat).
     * ----------------------------------------------------------------------- */
    $item_decisions_raw = isset( $_POST['item_decisions'] ) ? (string) wp_unslash( $_POST['item_decisions'] ) : '';
    $item_decisions     = [];
    if ( $item_decisions_raw !== '' ) {
        $decoded = json_decode( $item_decisions_raw, true );
        if ( is_array( $decoded ) ) {
            // Normalizar keys para int e valores para 0/1
            foreach ( $decoded as $k => $v ) {
                $item_decisions[ (int) $k ] = ( (int) $v === 1 ) ? 1 : 0;
            }
        }
    }

    if ( ! empty( $item_decisions ) ) {
        // Recarregar scenario_details (não foi seleccionado no get_row inicial)
        $proj_full = $wpdb->get_row( $wpdb->prepare(
            "SELECT scenario_details FROM $table WHERE id = %d",
            $project_id
        ) );
        $details = ( $proj_full && ! empty( $proj_full->scenario_details ) )
            ? json_decode( $proj_full->scenario_details, true )
            : [];
        if ( ! is_array( $details ) ) $details = [];

        foreach ( $details as $idx => &$item ) {
            $mp = isset( $item['must_pay'] ) ? (int) $item['must_pay'] : 1;
            // Só items negociáveis aceitam decisão do artista
            if ( $mp === 0 && isset( $item_decisions[ $idx ] ) ) {
                $item['artist_assumes'] = $item_decisions[ $idx ];
            } elseif ( $mp === 1 ) {
                // Coerência: obrigatório nunca tem assume
                $item['artist_assumes'] = 0;
            }
            // Artista viu a UI de escolha — marcar como decidido
            $item['decided'] = 1;
        }
        unset( $item );

        $wpdb->update(
            $table,
            [ 'scenario_details' => wp_json_encode( $details ) ],
            [ 'id' => $project_id ]
        );
    }

    $wpdb->update(
        $table,
        [
            'extra_data'        => wp_json_encode( $extra ),
            'production_status' => 'kyc_review',
        ],
        [ 'id' => $project_id ]
    );

    // Notificar admin
    $admin_email = get_option( 'rmbz_admin_notification_email', get_option( 'admin_email' ) );
    if ( $admin_email ) {
        @wp_mail(
            $admin_email,
            '🚨 KYC Recebido (via app): Valide a Identidade do Artista',
            sprintf(
                "O artista enviou via app a foto do BI para o videoclipe '%s'.\n\nTel: %s\n\nVai ao painel 'Gestão de Projetos' aprovar a identidade.",
                (string) $project->project_title,
                $tel
            )
        );
    }

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'message'      => 'Contrato assinado. A tua identidade está em análise pela equipa de segurança.',
    ] );
}


/* ============================================================================
 * POST /projects/{id}/decide-items
 *
 * [1.6.118.45] Artista decide items adicionados pelo admin APÓS aceitar
 * contrato (mini-prompt). Recebe array de decisões { idx, assumes }.
 *
 * Body esperado (JSON):
 *   { "decisions": [ { "idx": 5, "assumes": 1 }, { "idx": 7, "assumes": 0 } ] }
 *
 * Só aceita decisões para items com must_pay=0 E decided=0.
 * Items obrigatórios ou já decididos são ignorados (não dão erro — defensivo).
 * ============================================================================ */

function rmbz_api_handle_project_decide_items( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );

    $body = $request->get_json_params();
    if ( ! is_array( $body ) ) $body = [];
    $decisions = isset( $body['decisions'] ) && is_array( $body['decisions'] ) ? $body['decisions'] : [];

    if ( empty( $decisions ) ) {
        return rmbz_api_error( 'Nenhuma decisão enviada.', 422, 'no_decisions' );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $project = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, scenario_details, extra_data FROM $table WHERE id = %d AND artist_telemovel = %s",
        $project_id, $tel
    ) );

    if ( ! $project ) return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );

    $extra = json_decode( $project->extra_data, true );
    if ( ! is_array( $extra ) ) $extra = [];

    if ( empty( $extra['contract_accepted'] ) ) {
        return rmbz_api_error( 'Contrato ainda não aceite.', 409, 'contract_not_accepted' );
    }

    $details = ! empty( $project->scenario_details ) ? json_decode( $project->scenario_details, true ) : [];
    if ( ! is_array( $details ) || empty( $details ) ) {
        return rmbz_api_error( 'Orçamento vazio.', 409, 'no_budget' );
    }

    // Index decisões por idx para lookup rápido
    $by_idx = [];
    foreach ( $decisions as $d ) {
        if ( ! is_array( $d ) ) continue;
        $idx = isset( $d['idx'] ) ? (int) $d['idx'] : -1;
        $ass = ! empty( $d['assumes'] ) ? 1 : 0;
        if ( $idx >= 0 ) $by_idx[ $idx ] = $ass;
    }

    $updated_count = 0;
    foreach ( $details as $idx => &$item ) {
        $mp      = isset( $item['must_pay'] ) ? (int) $item['must_pay'] : 1;
        $decided = isset( $item['decided'] )  ? (int) $item['decided']  : 1;
        // Só items NEGOCIÁVEIS E NÃO DECIDIDOS aceitam decisão
        if ( $mp === 0 && $decided === 0 && isset( $by_idx[ $idx ] ) ) {
            $item['artist_assumes'] = $by_idx[ $idx ];
            $item['decided']        = 1;
            $updated_count++;
        }
    }
    unset( $item );

    if ( $updated_count === 0 ) {
        return rmbz_api_error( 'Nenhum item válido para decidir.', 409, 'no_valid_items' );
    }

    $wpdb->update(
        $table,
        [ 'scenario_details' => wp_json_encode( $details ) ],
        [ 'id' => $project_id ]
    );

    return rmbz_api_ok( [
        'updated_count' => $updated_count,
        'message'       => sprintf( '%d item(s) confirmados.', $updated_count ),
    ] );
}

/* ============================================================================
 * POST /projects/{id}/pay
 *
 * Paga produção debitando da carteira (mesma lógica do
 * onemz_pay_project_cost do form-handler).
 * ============================================================================ */

function rmbz_api_handle_project_pay( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );

    // PIN para autorizar débito (segurança extra — mesma lógica do subscription/buy)
    $body = $request->get_json_params();
    $pin = (string) ( $body['pin'] ?? '' );
    if ( strlen( $pin ) < 4 ) {
        return rmbz_api_error( 'PIN inválido.', 422, 'invalid_pin' );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $project = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM $table WHERE id = %d AND artist_telemovel = %s",
        $project_id, $tel
    ) );

    if ( ! $project ) return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );

    // Validar PIN (coluna user_pin — bug aprendido em 1.6.25.3!)
    $wallet = $wpdb->get_row( $wpdb->prepare(
        "SELECT user_pin FROM {$wpdb->prefix}rmbz_wallets WHERE user_telemovel = %s",
        $tel
    ) );
    if ( ! $wallet ) {
        return rmbz_api_error(
            'Carteira não encontrada para este telemóvel: ' . $tel,
            404, 'wallet_missing'
        );
    }
    // [1.6.118.12] Auto-recovery PIN texto plano (admin reset_pin histórico)
    if ( ! rmbz_verify_user_pin( $tel, $pin ) ) {
        return rmbz_api_error( 'PIN incorrecto.', 401, 'wrong_pin' );
    }

    $custo_total      = (float) $project->scenario_cost;           // total bruto (info)
    $obligatory_cost  = rmbz_calc_project_obligatory_cost( $project ); // [1.6.118.56] o que artista paga
    $ja_pago          = (float) $project->amount_paid;
    $valor_a_pagar    = $obligatory_cost - $ja_pago;

    if ( $valor_a_pagar <= 0 ) {
        return rmbz_api_error( 'Este projecto já está totalmente pago.', 409, 'already_paid' );
    }

    if ( ! function_exists( 'rmbz_get_wallet_balance_by_phone' ) ||
         ! function_exists( 'rmbz_update_wallet_balance' ) ) {
        return rmbz_api_error( 'Sistema de carteira indisponível.', 500, 'wallet_unavailable' );
    }

    $saldo = (float) rmbz_get_wallet_balance_by_phone( $tel );
    if ( $saldo < $valor_a_pagar ) {
        $falta = $valor_a_pagar - $saldo;
        return rmbz_api_error(
            'Saldo insuficiente. Faltam ' . number_format( $falta, 2, ',', '.' ) . ' MT na carteira.',
            422,
            'insufficient_balance',
            [
                'balance'         => $saldo,
                'amount_required' => $valor_a_pagar,
                'amount_missing'  => $falta,
            ]
        );
    }

    $details = ( $ja_pago == 0 )
        ? "Pagamento Inicial de Produção (Projecto #$project_id)"
        : "Pagamento Adicional de Produção (Projecto #$project_id)";

    $debit = rmbz_update_wallet_balance(
        $tel,
        -$valor_a_pagar,
        'production_payment',
        $project_id,
        $details
    );

    if ( empty( $debit['success'] ) ) {
        return rmbz_api_error(
            'Erro técnico ao debitar saldo: ' . ( $debit['message'] ?? 'desconhecido' ),
            500, 'debit_failed'
        );
    }

    $novo_total_pago = $ja_pago + $valor_a_pagar;
    // [1.6.118.56] is_paid usa obligatory_cost (não scenario_cost) — caso contrário
    // projecto em modo misto NUNCA seria marcado totalmente pago.
    $esta_pago = ( $novo_total_pago >= ( $obligatory_cost - 0.01 ) ) ? 1 : 0;

    $wpdb->update(
        $table,
        [
            'amount_paid'       => $novo_total_pago,
            'is_paid'           => $esta_pago,
            'production_status' => 'filming',
        ],
        [ 'id' => $project_id ]
    );

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'amount_paid'  => $valor_a_pagar,
        'total_paid'   => $novo_total_pago,
        // [1.6.118.56] remaining contra obligatory_cost (não scenario_cost)
        'remaining'    => max( 0, $obligatory_cost - $novo_total_pago ),
        'is_fully_paid' => (bool) $esta_pago,
        'message'      => 'Pagamento concluído com sucesso. Produção iniciada.',
    ] );
}


/* ============================================================================
 * POST /projects/pay-fine — [1.6.383] Artista paga a multa por atraso (carteira)
 * ============================================================================ */

function rmbz_api_handle_project_pay_fine( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $pin = (string) $request->get_param( 'pin' );
    if ( strlen( $pin ) < 4 ) return rmbz_api_error( 'PIN inválido.', 400, 'invalid_pin' );

    if ( ! function_exists( 'rmbz_project_pay_fine_from_wallet' ) ) {
        return rmbz_api_error( 'Função de multa indisponível.', 500, 'unavailable' );
    }

    $res = rmbz_project_pay_fine_from_wallet( $tel, $pin );
    if ( empty( $res['success'] ) ) {
        $code = $res['code'] ?? 'pay_fine_failed';
        $http = ( $code === 'wrong_pin' ) ? 401
              : ( in_array( $code, [ 'insufficient_balance', 'no_fine' ], true ) ? 409 : 400 );
        return rmbz_api_error( $res['message'] ?? 'Não foi possível pagar a multa.', $http, $code );
    }

    return rmbz_api_ok( [
        'success'      => true,
        'message'      => (string) $res['message'],
        'paid'         => (float) $res['paid'],
        'balance'      => (float) $res['balance'],
        'overdue_fine' => rmbz_project_overdue_info( $tel ),
    ] );
}


/* ============================================================================
 * POST /projects/{id}/feedback
 *
 * Envia mensagem do artista para a equipa (paridade com onemz_update_feedback).
 * ============================================================================ */

function rmbz_api_handle_project_feedback( WP_REST_Request $request ) {
    $tel = rmbz_api_proj_resolve_tel( $request );
    if ( $tel === '' ) return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );

    $body = $request->get_json_params();
    $message = trim( (string) ( $body['message'] ?? '' ) );
    if ( $message === '' ) {
        return rmbz_api_error( 'Mensagem vazia.', 422, 'missing_message' );
    }
    if ( mb_strlen( $message ) > 2000 ) {
        return rmbz_api_error( 'Mensagem demasiado longa (máx 2000 caracteres).', 422, 'too_long' );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $project = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, artist_feedback FROM $table WHERE id = %d AND artist_telemovel = %s",
        $project_id, $tel
    ) );

    if ( ! $project ) return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );

    $clean = sanitize_textarea_field( $message );
    $timestamp = current_time( 'd/m/Y H:i' );
    $entry = "[$timestamp] <strong>Artista:</strong> $clean";

    $updated = $project->artist_feedback;
    if ( ! empty( $updated ) ) {
        $updated .= "\n\n" . $entry;
    } else {
        $updated = $entry;
    }

    $wpdb->update(
        $table,
        [ 'artist_feedback' => $updated ],
        [ 'id' => $project_id ]
    );

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'message'      => 'Mensagem enviada à equipa.',
    ] );
}


/* ============================================================================
 * [1.6.27.2] POST /projects/{id}/reject-contract
 * Artista recusa o contrato com motivo. Define contract_rejected = true
 * no extra_data; admin recebe notificação e pode enviar versão revista.
 * Replica onemz_reject_contract_action do form-handler.php.
 * ============================================================================ */

function rmbz_api_handle_project_reject_contract( WP_REST_Request $request ) {
    $telemovel = rmbz_api_proj_resolve_tel( $request );
    if ( $telemovel === '' ) {
        return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );
    }

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) {
        return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );
    }

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

    if ( $reason === '' || mb_strlen( $reason ) < 5 ) {
        return rmbz_api_error( 'Indica o motivo da recusa (mínimo 5 caracteres).', 400, 'reason_required' );
    }
    if ( mb_strlen( $reason ) > 500 ) {
        return rmbz_api_error( 'O motivo é demasiado longo (máx 500 caracteres).', 400, 'reason_too_long' );
    }

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

    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, extra_data FROM {$table} WHERE id = %d AND artist_telemovel = %s LIMIT 1",
        $project_id, $telemovel
    ) );
    if ( ! $row ) {
        return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );
    }

    $extra = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    if ( ! is_array( $extra ) ) $extra = [];

    if ( empty( $extra['contract_url'] ) ) {
        return rmbz_api_error( 'Ainda não há contrato para recusar.', 400, 'no_contract' );
    }
    if ( ! empty( $extra['contract_accepted'] ) ) {
        return rmbz_api_error( 'O contrato já foi aceite e não pode ser recusado.', 400, 'already_accepted' );
    }

    $extra['contract_accepted']        = false;
    $extra['contract_rejected']        = true;
    $extra['contract_rejected_reason'] = $reason;
    // Limpa eventual KYC anterior — se admin enviar novo contrato, KYC tem
    // de ser refeito.
    if ( isset( $extra['kyc_status'] ) ) {
        unset( $extra['kyc_status'] );
    }

    $wpdb->update(
        $table,
        [ 'extra_data' => wp_json_encode( $extra ) ],
        [ 'id' => $project_id ]
    );

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'message'      => 'A tua objeção foi enviada. Aguarda uma versão revista do contrato.',
    ] );
}

/* ============================================================================
 * [1.6.118.64] POST /projects/{id}/confirm-filming-dates
 *
 * Artista confirma as datas de gravação que o admin definiu.
 * Limpa qualquer contra-proposta pendente.
 * ============================================================================ */
function rmbz_api_handle_project_confirm_filming( WP_REST_Request $request ) {
    $telemovel = rmbz_api_proj_resolve_tel( $request );
    if ( $telemovel === '' ) {
        return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );
    }

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) {
        return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, extra_data FROM {$table} WHERE id = %d AND artist_telemovel = %s LIMIT 1",
        $project_id, $telemovel
    ) );
    if ( ! $row ) {
        return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );
    }

    $extra = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    if ( ! is_array( $extra ) ) $extra = [];

    if ( empty( $extra['filming_start'] ) && empty( $extra['filming_end'] ) ) {
        return rmbz_api_error( 'Ainda não há datas de gravação para confirmar.', 400, 'no_dates' );
    }

    $extra['filming_confirmed_by_artist'] = true;
    unset( $extra['filming_proposed_by_artist'] );

    $wpdb->update(
        $table,
        [ 'extra_data' => wp_json_encode( $extra ) ],
        [ 'id' => $project_id ]
    );

    // Notificar admin (chat do projecto) que o artista confirmou
    $ts = current_time( 'd/m/Y H:i' );
    $entry = "[$ts] <strong>Artista:</strong> ✅ Confirmou as datas de gravação.";
    $prev = (string) ( $wpdb->get_var( $wpdb->prepare(
        "SELECT artist_feedback FROM {$table} WHERE id = %d", $project_id
    ) ) ?? '' );
    $new_feedback = $prev === '' ? $entry : $prev . "\n\n" . $entry;
    $wpdb->update( $table, [ 'artist_feedback' => $new_feedback ], [ 'id' => $project_id ] );

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'message'      => 'Datas de gravação confirmadas. A equipa foi notificada.',
    ] );
}

/* ============================================================================
 * [1.6.118.64] POST /projects/{id}/propose-filming-dates
 *
 * Artista propõe datas alternativas. Body: { start: 'Y-m-d', end: 'Y-m-d' }.
 * Guarda em filming_proposed_by_artist (não altera as datas oficiais — o
 * admin é que decide aceitar). Marca confirmed=false.
 * ============================================================================ */
function rmbz_api_handle_project_propose_filming( WP_REST_Request $request ) {
    $telemovel = rmbz_api_proj_resolve_tel( $request );
    if ( $telemovel === '' ) {
        return rmbz_api_error( 'Sessão inválida.', 401, 'auth_required' );
    }

    $project_id = (int) $request->get_param( 'id' );
    if ( $project_id <= 0 ) {
        return rmbz_api_error( 'ID inválido.', 400, 'invalid_id' );
    }

    $body = $request->get_json_params();
    if ( ! is_array( $body ) ) $body = [];
    $start_raw = isset( $body['start'] ) ? sanitize_text_field( (string) $body['start'] ) : '';
    $end_raw   = isset( $body['end'] )   ? sanitize_text_field( (string) $body['end'] )   : '';

    // Validar e normalizar
    $start = ( $start_raw !== '' && strtotime( $start_raw ) ) ? date( 'Y-m-d', strtotime( $start_raw ) ) : '';
    $end   = ( $end_raw !== '' && strtotime( $end_raw ) )     ? date( 'Y-m-d', strtotime( $end_raw ) )   : '';

    if ( $start === '' || $end === '' ) {
        return rmbz_api_error( 'Indica a data de início e de fim.', 400, 'dates_required' );
    }
    // Coerência: fim >= início
    if ( strtotime( $end ) < strtotime( $start ) ) {
        return rmbz_api_error( 'A data de fim não pode ser anterior à de início.', 400, 'invalid_range' );
    }
    // Não permitir propor datas no passado
    if ( strtotime( $start ) < strtotime( date( 'Y-m-d' ) ) ) {
        return rmbz_api_error( 'Não podes propor uma data no passado.', 400, 'past_date' );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'rmbz_artist_projects';
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, extra_data FROM {$table} WHERE id = %d AND artist_telemovel = %s LIMIT 1",
        $project_id, $telemovel
    ) );
    if ( ! $row ) {
        return rmbz_api_error( 'Projecto não encontrado.', 404, 'not_found' );
    }

    $extra = ! empty( $row->extra_data ) ? json_decode( $row->extra_data, true ) : [];
    if ( ! is_array( $extra ) ) $extra = [];

    $extra['filming_proposed_by_artist'] = [
        'start' => $start,
        'end'   => $end,
        'at'    => current_time( 'mysql', 1 ),
    ];
    $extra['filming_confirmed_by_artist'] = false;

    $wpdb->update(
        $table,
        [ 'extra_data' => wp_json_encode( $extra ) ],
        [ 'id' => $project_id ]
    );

    // Notificar admin no chat
    $ts = current_time( 'd/m/Y H:i' );
    $ds = date_i18n( 'd/m/Y', strtotime( $start ) );
    $de = date_i18n( 'd/m/Y', strtotime( $end ) );
    $entry = "[$ts] <strong>Artista:</strong> 📅 Propôs novas datas de gravação: {$ds} a {$de}.";
    $prev = (string) ( $wpdb->get_var( $wpdb->prepare(
        "SELECT artist_feedback FROM {$table} WHERE id = %d", $project_id
    ) ) ?? '' );
    $new_feedback = $prev === '' ? $entry : $prev . "\n\n" . $entry;
    $wpdb->update( $table, [ 'artist_feedback' => $new_feedback ], [ 'id' => $project_id ] );

    return rmbz_api_ok( [
        '_api_version' => RMBZ_API_PROJECTS_VERSION,
        'success'      => true,
        'project_id'   => $project_id,
        'message'      => 'A tua proposta foi enviada. A equipa vai rever e responder.',
    ] );
}
