<?php
if ( ! defined( 'ABSPATH' ) ) exit;

require_once SCHOLAR_SYNC_PLUGIN_DIR . '/admin/scholar_sync_functions.php';

add_action(
    'rest_api_init',
    function () {
        $controller = new ScholarSync_REST_API;
        $controller->register_routes();
    }
    ,10, 0
);

class ScholarSync_REST_API
{

    const route_namespace = 'sclr/v1';

    // Columns selected for the public article display endpoints. Internal columns
    // (is_*, cited_by_value_sync, author_id, citation_id, timestamps) are never exposed.
    // all_data is selected only to resolve a fallback link server-side;
    // shape_public_article() drops it before the response is returned.
    const PUBLIC_ARTICLE_COLUMNS = 'article_id, title, link, authors, publication, cited_by_value, year, image_url, all_data';

    /**
     * Reduce a raw article row to public display fields only, resolving the
     * fallback link from all_data server-side so the raw blob is never returned
     * to unauthenticated REST consumers. Mirrors the link order used at sync time
     * (see ScholarSync_Author_Sync): doi → landing_page_url → oa_url → id.
     *
     * @param array $row Raw row (ARRAY_A) including all_data.
     * @return array Public-only article fields.
     */
    private static function shape_public_article( $row ) {
        $link = isset( $row['link'] ) ? (string) $row['link'] : '';
        if ( $link === '' && ! empty( $row['all_data'] ) ) {
            $data = json_decode( $row['all_data'], true );
            if ( is_array( $data ) ) {
                $link = $data['doi']
                    ?? $data['primary_location']['landing_page_url']
                    ?? $data['open_access']['oa_url']
                    ?? $data['id']
                    ?? '';
            }
        }
        return array(
            'article_id'     => isset( $row['article_id'] ) ? (int) $row['article_id'] : 0,
            'title'          => isset( $row['title'] ) ? $row['title'] : '',
            'link'           => $link,
            'authors'        => isset( $row['authors'] ) ? $row['authors'] : '',
            'publication'    => isset( $row['publication'] ) ? $row['publication'] : '',
            'cited_by_value' => isset( $row['cited_by_value'] ) ? (int) $row['cited_by_value'] : 0,
            'year'           => isset( $row['year'] ) ? (int) $row['year'] : 0,
            'image_url'      => isset( $row['image_url'] ) ? (string) $row['image_url'] : '',
        );
    }

    public function admin_permission_callback() {
        return current_user_can('edit_others_posts');
    }

    public function manage_options_permission_callback() {
        return current_user_can( 'manage_options' );
    }

    /**
     * Maps raw service error codes to localised, user-friendly messages.
     *
     * Error strings returned by the scholar-sync.org service are machine codes.
     * This method converts them to translated sentences shown in the admin UI.
     * Add new cases here as the service introduces new error codes.
     *
     * @param string $raw_error  The raw error string from the service.
     * @return string            Translated, human-readable message (may contain HTML).
     */
    public static function translate_service_error( string $raw_error ): string {

        $upgrade_url = 'https://scholar-sync.org/#pricing';

        // error_exceed_sync_frequency — raw_error may carry limit_value separately,
        // but the code itself is the signal; show a generic "too soon" message here.
        // (limit_value days are surfaced via plan_limit_message when data is available.)
        // Newer "sync_frequency max[N]" block, where N is the number of days until the
        // next sync is allowed — surface it as a concrete wait time.
        if ( preg_match( '/^sync_frequency\s+max\[(\d+)\]$/', $raw_error, $m ) ) {
            $days = (int) $m[1];
            $msg  = sprintf(
                /* translators: %d: number of days until the next sync is allowed */
                _n(
                    'This researcher was synced too recently. Please wait %d day before syncing again.',
                    'This researcher was synced too recently. Please wait %d days before syncing again.',
                    $days,
                    'scholar-sync'
                ),
                $days
            );
            return $msg . ' <a href="' . esc_url( $upgrade_url ) . '" target="_blank">'
                   . __( 'Upgrade to a premium plan', 'scholar-sync' )
                   . '</a> ' . __( 'for more frequent syncing.', 'scholar-sync' );
        }

        // error_exceed_sync_frequency, or a sync_frequency block without a parseable
        // day count — same "synced too recently" case, shown with a generic wait message.
        if ( $raw_error === 'error_exceed_sync_frequency' || str_starts_with( $raw_error, 'sync_frequency' ) ) {
            return __( 'This researcher was synced too recently. Please wait before syncing again.', 'scholar-sync' )
                   . ' <a href="' . esc_url( $upgrade_url ) . '" target="_blank">'
                   . __( 'Upgrade to a premium plan', 'scholar-sync' )
                   . '</a> ' . __( 'for more frequent syncing.', 'scholar-sync' );
        }

        // sync_in_progress days[N]
        if ( preg_match( '/^sync_in_progress\s+days\[(\d+)\]$/', $raw_error, $m ) ) {
            $days = (int) $m[1];
            $msg = sprintf(
                /* translators: %d: number of days until the next sync is allowed */
                _n(
                    'This researcher was recently synced. Please wait %d day before syncing again.',
                    'This researcher was recently synced. Please wait %d days before syncing again.',
                    $days,
                    'scholar-sync'
                ),
                $days
            );
            $msg .= ' ' . sprintf(
                /* translators: %s: URL to the upgrade / pricing page */
                __( '<a href="%s" target="_blank">Upgrade to a premium plan</a> for more frequent syncing.', 'scholar-sync' ),
                esc_url( $upgrade_url )
            );
            return $msg;
        }

        $error_map = array(
            'author_not_found'           => __( 'The researcher could not be found on Google Scholar. Please verify the profile ID.', 'scholar-sync' ),
            'invalid_author'             => __( 'The researcher ID is invalid. Please search again and select a valid profile.', 'scholar-sync' ),
            'invalid_apikey'             => __( 'Your Scholar Sync API key is invalid. Please reconnect the plugin from the Settings page.', 'scholar-sync' ),
            'domain_mismatch'            => __( 'Your API key is registered to a different domain. Please reconnect the plugin from the Settings page.', 'scholar-sync' ),
            'quota_exceeded'             => __( 'Your monthly request quota has been reached.', 'scholar-sync' )
                                           . ' <a href="' . esc_url( $upgrade_url ) . '" target="_blank">'
                                           . __( 'Upgrade your plan', 'scholar-sync' )
                                           . '</a>.',
            'service_unavailable'        => __( 'The Scholar Sync service is temporarily unavailable. Please try again in a few minutes.', 'scholar-sync' ),
            'error_invalid_transaction'  => __( 'The sync session is no longer valid. Please try syncing again.', 'scholar-sync' ),
            'error_transaction_expired'  => __( 'The sync session expired (1-hour limit). Please try syncing again.', 'scholar-sync' ),
            'error_exceed_authors'       => __( 'You have reached the author limit for your current plan.', 'scholar-sync' )
                                           . ' <a href="' . esc_url( $upgrade_url ) . '" target="_blank">'
                                           . __( 'Upgrade your plan', 'scholar-sync' )
                                           . '</a>.',
        );

        if ( isset( $error_map[ $raw_error ] ) ) {
            return $error_map[ $raw_error ];
        }

        // Unknown error — show the raw code so it can be reported.
        /* translators: %s: raw error code returned by the service */
        return sprintf( __( 'An unexpected error occurred: %s', 'scholar-sync' ), esc_html( $raw_error ) );
    }

    /**
     * Returns the best available upgrade URL given the current Freemius state.
     *
     * - Freemius registered (opt-in done) + plans configured → in-plugin checkout page
     * - Freemius present but not yet registered              → settings page to complete opt-in
     * - Freemius unavailable                                 → external pricing page
     *
     * @return string Absolute URL.
     */
    public static function get_upgrade_url(): string {
        if ( function_exists( 'scholar_sync_fs' ) ) {
            return scholar_sync_fs()->get_upgrade_url();
        }
        return 'https://scholar-sync.org/#pricing';
    }

    /**
     * Builds a detailed, human-readable plan-limit message from the structured
     * data returned by the service (limit_type, limit_value, plan).
     *
     * Nothing is hardcoded — every value comes from the service response.
     *
     * @param array  $limit_data  The data array from the service error response.
     * @param string $upgrade_url URL to the Freemius upgrade/checkout page.
     * @return string             Translated HTML message with an upgrade link.
     */
    public static function plan_limit_message( array $limit_data, string $upgrade_url ): string {

        $limit_type       = $limit_data['limit_type']       ?? '';
        $limit_value      = isset( $limit_data['limit_value'] ) ? (int) $limit_data['limit_value'] : 0;
        $upgrade_required = ! empty( $limit_data['upgrade_required'] );
        $plan             = isset( $limit_data['plan'] ) ? ucfirst( sanitize_text_field( $limit_data['plan'] ) ) : '';
        $next_sync        = $limit_data['next_sync_available'] ?? '';

        // ── Sync-frequency limit ────────────────────────────────────────
        if ( $limit_type === 'min_days_4_partial_update' ) {
            if ( $next_sync ) {
                $ts = strtotime( $next_sync );
                // Wrap in a data-utc span so JS (scholarSyncLocalizeTimes(), admin/js/scholar-sync-admin.js)
                // can replace the text with the viewer's own local time — no timezone label needed
                // once it's rendered in the browser's own zone. The GMT-labeled text is the fallback
                // if JS doesn't run.
                $display = $ts
                    ? '<span class="scholar-sync-local-time" data-utc="' . esc_attr( gmdate( 'c', $ts ) ) . '">'
                        . esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) . ' T', $ts ) )
                        . '</span>'
                    : esc_html( $next_sync );
                $msg = sprintf(
                    /* translators: %s: formatted date/time of next available sync */
                    __( 'This researcher was synced too recently. The next sync will be available on %s.', 'scholar-sync' ),
                    '<strong>' . $display . '</strong>'
                );
            } elseif ( $limit_value ) {
                $msg = sprintf(
                    /* translators: %d: number of days to wait */
                    _n(
                        'This researcher was synced too recently. Please wait %d day before syncing again.',
                        'This researcher was synced too recently. Please wait %d days before syncing again.',
                        $limit_value,
                        'scholar-sync'
                    ),
                    $limit_value
                );
            } else {
                $msg = __( 'This researcher was synced too recently. Please wait before syncing again.', 'scholar-sync' );
            }

            if ( $upgrade_required ) {
                $msg .= '<br><a href="' . esc_url( $upgrade_url ) . '" target="_blank">'
                      . __( 'Upgrade your plan', 'scholar-sync' )
                      . '</a> ' . __( 'for more frequent syncing.', 'scholar-sync' );
            }

            return $msg;
        }

        // ── General plan-limit message (max authors, max articles, etc.) ─
        // "max_authors" → "authors", "max_articles_per_author" → "articles per author"
        $limit_type_readable = $limit_type
            ? str_replace( '_', ' ', preg_replace( '/^max_/', '', sanitize_text_field( $limit_type ) ) )
            : '';

        if ( $plan && $limit_value && $limit_type_readable ) {
            $detail = sprintf(
                /* translators: 1: plan name (e.g. Free), 2: numeric limit, 3: resource name (e.g. authors) */
                __( 'Your %1$s plan allows a maximum of %2$d %3$s.', 'scholar-sync' ),
                $plan, $limit_value, $limit_type_readable
            );
        } elseif ( $limit_value && $limit_type_readable ) {
            $detail = sprintf(
                /* translators: 1: numeric limit, 2: resource name */
                __( 'You have reached the maximum of %1$d %2$s.', 'scholar-sync' ),
                $limit_value, $limit_type_readable
            );
        } else {
            $detail = __( 'You have reached the limit of your current plan.', 'scholar-sync' );
        }

        if ( ! $upgrade_required ) {
            return $detail;
        }

        $is_settings  = ( strpos( $upgrade_url, 'scholar-sync-settings-slug' ) !== false && strpos( $upgrade_url, 'pricing' ) === false );
        $link_label   = $is_settings
            ? __( 'Connect your account to upgrade', 'scholar-sync' )
            : __( 'Upgrade your plan', 'scholar-sync' );
        $upgrade_link = sprintf( ' <a href="%s">%s</a>', esc_url( $upgrade_url ), $link_label );

        return $detail . $upgrade_link . ' ' . __( 'to continue.', 'scholar-sync' );
    }

    public function register_routes() {
        /**
         * Add / sync author — admin only.
         * Uses /customer/sync (not /customer/sync-author) to avoid conflicting with the
         * scholar-sync-service plugin's route of the same name on scholar-sync.org.
         */
        register_rest_route( self::route_namespace, '/customer/sync',
            array(
                array(
                    'methods' => WP_REST_Server::CREATABLE,
                    'callback' => array( $this, 'get_add_author' ),
                    'permission_callback' => array( $this, 'admin_permission_callback' ),
                )
            )
        );

        /**
         * Update a plugin option — requires manage_options (site admin only)
         */
        register_rest_route( self::route_namespace, '/add-option',
            array(
                array(
                    'methods' => WP_REST_Server::CREATABLE,
                    'callback' => array( $this, 'add_user_option' ),
                    'permission_callback' => array( $this, 'manage_options_permission_callback' ),
                )
            )
        );

        /**
         * Get sync results — admin only
         */
        register_rest_route( self::route_namespace, '/get-sync-results',
            array(
                array(
                    'methods' => WP_REST_Server::READABLE,
                    'callback' => array( $this, 'get_today_sync_status' ),
                    'permission_callback' => array( $this, 'admin_permission_callback' ),
                )
            )
        );

        /**
         * Group display data (group + members + combined articles) — public read
         */
        register_rest_route( self::route_namespace, '/group-display',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_group_display' ),
                    'permission_callback' => '__return_true',
                )
            )
        );

        /**
         * Display author articles — public read
         */
        register_rest_route( self::route_namespace, '/display',
            array(
                array(
                    'methods' => WP_REST_Server::READABLE,
                    'callback' => array($this, 'get_display'),
                    'permission_callback' => '__return_true',
                )
            )
        );

    }

    /**
     * Sync an existing author — delegates to get_add_author with action=sync.
     */
    public function sync_author( WP_REST_Request $request ) {
        return $this->get_add_author( $request );
    }

    public function get_add_author( WP_REST_Request $request ) {

        $scholar_id     = sanitize_text_field( (string) ( $request->get_param( 'author' ) ?? '' ) );
        $transaction_id = sanitize_text_field( (string) ( $request->get_param( 'transaction_id' ) ?? '' ) );

        if ( $scholar_id === '' ) {
            return new WP_Error( 'missing_id', __( 'Scholar ID is required.', 'scholar-sync' ), array( 'status' => 400 ) );
        }
        $is_first_call  = ( $transaction_id === '' );

        require_once SCHOLAR_SYNC_PLUGIN_DIR . '/admin/class-scholar-sync-author-sync.php';
        $author = new ScholarSync_Author_Sync( $scholar_id, $transaction_id );

        $profile_presaved = (bool) $request->get_param( 'profile_presaved' );

        if ( $is_first_call && $request->get_param( 'action' ) === 'add' && $author->author_exists() && ! $profile_presaved ) {
            $response = array(
                'author_id' => $author->get_author_id(),
                'has_error' => true,
                'message'   => __( 'Researcher already exists', 'scholar-sync' ),
            );
        } else {

            set_error_handler( function() { /* ignore errors */ } ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- intentional suppression of non-fatal errors during remote sync
            $author->add();
            restore_error_handler();

            if ( $author->has_error() ) {
                if ( $author->is_plan_limit_error() ) {
                    $upgrade_url = self::get_upgrade_url();
                    $limit_data  = $author->get_plan_limit_data();
                    $response = array(
                        'has_error'        => true,
                        'upgrade_required' => ! empty( $limit_data['upgrade_required'] ),
                        'upgrade_url'      => $upgrade_url,
                        'message'          => self::plan_limit_message( $limit_data, $upgrade_url ),
                    );
                } else {
                    $response = array(
                        'has_error' => true,
                        'message'   => self::translate_service_error( $author->get_message() ),
                    );
                }
            } else {
                $next_transaction_id = $author->get_next_transaction_id();
                $results             = $author->get_articles_results();

                if ( $next_transaction_id === '' ) {
                    // Final page — log cumulative totals to sync history.
                    $running_created = (int) ( $request->get_param( 'running_created' ) ?? 0 );
                    $running_updated = (int) ( $request->get_param( 'running_updated' ) ?? 0 );
                    require_once SCHOLAR_SYNC_PLUGIN_DIR . '/includes/class-scholar-sync-background-sync-runner.php';
                    ScholarSync_Background_Sync_Runner::log_manual_sync(
                        (int) $author->get_author_id(),
                        $scholar_id,
                        $running_created + (int) ( $results['articles_created'] ?? 0 ),
                        $running_updated + (int) ( $results['articles_updated'] ?? 0 )
                    );
                }

                $response = array(
                    'scholar_id'     => $scholar_id,
                    'author_id'      => $author->get_author_id(),
                    'message'        => $author->get_articles_count_message(),
                    'transaction_id' => $next_transaction_id !== '' ? $next_transaction_id : null,
                );
                $response = array_merge( $response, $results );
            }
        }
        return rest_ensure_response( $response );
    }

    public function get_display( WP_REST_Request $request ) {
        global $wpdb;

        $author_id = absint( $request->get_param( 'id' ) );
        $search    = sanitize_text_field( (string) $request->get_param( 'search' ) );
        $per_page  = (int) $request->get_param( 'per_page' );
        if ( ! in_array( $per_page, array( 20, 50, 100 ), true ) ) {
            $per_page = 20;
        }
        $page   = max( 1, (int) $request->get_param( 'page' ) );
        $offset = ( $page - 1 ) * $per_page;

        $table = SCHOLAR_SYNC_DB_ARTICLES;

        // An empty $search yields LIKE '%%', which matches every row (i.e. no filtering), so the
        // search condition is always present and every query below is fully prepared with a fixed
        // set of placeholders — no value is ever interpolated into the SQL string.
        $like = '%' . $wpdb->esc_like( $search ) . '%';

        $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare(
                'SELECT COUNT(*) FROM %i WHERE author_id = %d AND is_active = 1 AND ( title LIKE %s OR authors LIKE %s OR publication LIKE %s )',
                $table, $author_id, $like, $like, $like
            )
        );

        // Column list is inlined (it mirrors self::PUBLIC_ARTICLE_COLUMNS) so it stays a string
        // literal; the two branches differ only by a hardcoded ORDER BY.
        $sort_by = sanitize_key( (string) ( $request->get_param( 'sort_by' ) ?? '' ) );
        if ( $sort_by === 'year' ) {
            $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->prepare(
                    'SELECT article_id, title, link, authors, publication, cited_by_value, year, image_url, all_data FROM %i WHERE author_id = %d AND is_active = 1 AND ( title LIKE %s OR authors LIKE %s OR publication LIKE %s ) ORDER BY year DESC, cited_by_value DESC LIMIT %d OFFSET %d',
                    $table, $author_id, $like, $like, $like, $per_page, $offset
                ),
                ARRAY_A
            );
        } else {
            $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->prepare(
                    'SELECT article_id, title, link, authors, publication, cited_by_value, year, image_url, all_data FROM %i WHERE author_id = %d AND is_active = 1 AND ( title LIKE %s OR authors LIKE %s OR publication LIKE %s ) ORDER BY cited_by_value DESC, year DESC LIMIT %d OFFSET %d',
                    $table, $author_id, $like, $like, $like, $per_page, $offset
                ),
                ARRAY_A
            );
        }
        $results = array_map( array( __CLASS__, 'shape_public_article' ), is_array( $results ) ? $results : array() );

        // Fetch scholar_id so JS can build fallback article links.
        $table_authors = SCHOLAR_SYNC_DB_AUTHORS;
        $author = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT scholar_id, name, thumbnail FROM %i WHERE author_id = %d', $table_authors, $author_id ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
        );

        // Build author_photos map: short_name → ['name', 'image_url'].
        // Co-authors loaded first; primary author overwrites on collision.
        // Unfiltered SELECT mirrors get_group_display() — intentional.
        $author_photos = array();

        $table_co_authors = SCHOLAR_SYNC_DB_CO_AUTHORS;
        $co_author_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT short_name, full_name AS name, image_url, profile_link FROM %i', $table_co_authors ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
            ARRAY_A
        );
        foreach ( $co_author_rows as $row ) {
            $entry = array(
                'name'         => $row['name'],
                'image_url'    => $row['image_url'] ?: null,
                'profile_link' => $row['profile_link'] ?: null,
            );
            // Index by short_name for the standard JS lookup.
            if ( ! empty( $row['short_name'] ) ) {
                $author_photos[ $row['short_name'] ] = $entry;
            }
            // Also index by full_name so the JS fallback lookup always works.
            if ( ! empty( $row['name'] ) ) {
                $author_photos[ $row['name'] ] = $entry;
            }
        }

        // Universal pass: every tracked/primary author overwrites the co_authors entry on
        // short_name collision, so a tracked author mentioned as a co-author on someone else's
        // article (or a custom author never touched by insert_co_authors()) still gets matched
        // anywhere on the site. Unfiltered SELECT mirrors the co_authors query above.
        // Overwritten below by the self overlay, which still wins.
        $table_all_authors = SCHOLAR_SYNC_DB_AUTHORS;
        $all_author_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT name, thumbnail, directory_link FROM %i', $table_all_authors ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
            ARRAY_A
        );
        foreach ( $all_author_rows as $row ) {
            $short = scholar_sync_generate_short_name( $row['name'] ?? '' );
            if ( $short === '' ) {
                continue;
            }
            // Don't regress a good co_authors photo to blank just because this tracked author
            // (e.g. a fresh custom author) has no thumbnail of their own yet.
            $existing_image = $author_photos[ $short ]['image_url'] ?? null;
            $entry = array(
                'name'         => $row['name'],
                'image_url'    => ! empty( $row['thumbnail'] ) ? $row['thumbnail'] : $existing_image,
                'profile_link' => ! empty( $row['directory_link'] ) ? $row['directory_link'] : null,
            );
            $author_photos[ $short ] = $entry;
            if ( ! empty( $row['name'] ) ) {
                $author_photos[ $row['name'] ] = $entry;
            }
        }

        // Primary author overwrites co-author entry (higher quality photo).
        if ( $author ) {
            $primary_short = scholar_sync_generate_short_name( $author->name ?? '' );
            if ( $primary_short !== '' ) {
                $author_photos[ $primary_short ] = array(
                    'name'         => $author->name ?? '',
                    'image_url'    => ( ! empty( $author->thumbnail ) ) ? $author->thumbnail : null,
                    'profile_link' => null,
                );
            }
        }

        return rest_ensure_response( array(
            'articles'      => $results,
            'total'         => $total,
            'pages'         => $total > 0 ? (int) ceil( $total / $per_page ) : 1,
            'current_page'  => $page,
            'per_page'      => $per_page,
            'scholar_id'    => $author ? $author->scholar_id : '',
            'author_photos' => $author_photos,
        ) );
    }

    public function get_group_display( WP_REST_Request $request ) {
        // Note: co_authors.short_name is pre-populated by scholar_sync_generate_short_name()
        // (the same algorithm used by JS). Keys will match JS lookups as long as records
        // were inserted via the standard sync path.
        global $wpdb;

        $group_id = absint( $request->get_param( 'id' ) );
        if ( ! $group_id ) {
            return new WP_Error( 'missing_id', __( 'Group ID is required.', 'scholar-sync' ), array( 'status' => 400 ) );
        }

        $table_groups = SCHOLAR_SYNC_DB_GROUPS;
        $group = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT * FROM %i WHERE group_id = %d', $table_groups, $group_id ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
        );
        if ( ! $group ) {
            return new WP_Error( 'not_found', __( 'Group not found.', 'scholar-sync' ), array( 'status' => 404 ) );
        }

        $settings = array();
        if ( $group->settings ) {
            $decoded = json_decode( $group->settings, true );
            if ( is_array( $decoded ) ) {
                $settings = $decoded;
            }
        }

        // Members ordered by sort_order; effective_link = group override → directory_link fallback.
        // Grid view paginates (authors_page/authors_per_page); feed view always loads every
        // member (needed for its author_photos matching below), same as before.
        $table_authors = SCHOLAR_SYNC_DB_AUTHORS;
        $table_group_authors = SCHOLAR_SYNC_DB_GROUP_AUTHORS;
        $is_grid = ( $group->view_type === 'grid' );

        $authors_total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE group_id = %d', $table_group_authors, $group_id ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
        );

        $authors_per_page = 0;
        $authors_page     = 1;
        $authors_pages    = 1;
        if ( $is_grid ) {
            $authors_per_page = (int) $request->get_param( 'authors_per_page' );
            if ( $authors_per_page <= 0 ) {
                $authors_per_page = isset( $settings['per_page'] ) ? (int) $settings['per_page'] : 12;
            }
            $authors_per_page = max( 4, min( 48, $authors_per_page ) );
            $authors_page     = max( 1, (int) $request->get_param( 'authors_page' ) );
            $authors_pages    = $authors_total > 0 ? (int) ceil( $authors_total / $authors_per_page ) : 1;
        }

        if ( $is_grid ) {
            $authors = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
                    "SELECT a.author_id, a.name, a.position, a.thumbnail, a.scholar_id, a.directory_link,
                            a.affiliations, a.interests,
                            COALESCE( NULLIF( ga.link_url, '' ), NULLIF( a.directory_link, '' ) ) AS effective_link
                     FROM %i a
                     INNER JOIN %i ga ON ga.author_id = a.author_id
                     WHERE ga.group_id = %d
                     ORDER BY ga.sort_order ASC
                     LIMIT %d OFFSET %d",
                    $table_authors,
                    $table_group_authors,
                    $group_id,
                    $authors_per_page,
                    ( $authors_page - 1 ) * $authors_per_page
                ),
                ARRAY_A
            );
        } else {
            $authors = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
                    "SELECT a.author_id, a.name, a.position, a.thumbnail, a.scholar_id, a.directory_link,
                            a.affiliations, a.interests,
                            COALESCE( NULLIF( ga.link_url, '' ), NULLIF( a.directory_link, '' ) ) AS effective_link
                     FROM %i a
                     INNER JOIN %i ga ON ga.author_id = a.author_id
                     WHERE ga.group_id = %d
                     ORDER BY ga.sort_order ASC",
                    $table_authors,
                    $table_group_authors,
                    $group_id
                ),
                ARRAY_A
            );
        }

        // Publication counts for the authors on this page — one batched query, not per-card.
        if ( ! empty( $authors ) ) {
            $count_author_ids = wp_list_pluck( $authors, 'author_id' );
            $count_placeholders = implode( ',', array_fill( 0, count( $count_author_ids ), '%d' ) );
            $table_articles_for_counts = SCHOLAR_SYNC_DB_ARTICLES;
            $count_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->prepare(
                    "SELECT author_id, COUNT(*) AS cnt FROM %i WHERE author_id IN ( {$count_placeholders} ) AND is_active = 1 GROUP BY author_id", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+); $count_placeholders is array_fill()-generated %d only
                    array_merge( array( $table_articles_for_counts ), $count_author_ids )
                ),
                ARRAY_A
            );
            $publication_counts = array();
            foreach ( $count_rows as $count_row ) {
                $publication_counts[ (int) $count_row['author_id'] ] = (int) $count_row['cnt'];
            }
            foreach ( $authors as &$author_row_ref ) {
                $author_row_ref['publication_count'] = $publication_counts[ (int) $author_row_ref['author_id'] ] ?? 0;
            }
            unset( $author_row_ref );
        }

        // Articles with optional search + pagination (used by feed view).
        $search   = sanitize_text_field( (string) $request->get_param( 'search' ) );
        $sort_by  = sanitize_key( (string) ( $request->get_param( 'sort_by' ) ?? '' ) );
        $per_page = (int) $request->get_param( 'per_page' );
        if ( ! in_array( $per_page, array( 20, 50, 100 ), true ) ) {
            $per_page = 0; // 0 = return all (used by grid view and admin preview).
        }
        $page           = max( 1, (int) $request->get_param( 'page' ) );
        $articles       = array();
        $total_articles = 0;
        $pages          = 1;

        if ( ! empty( $authors ) ) {
            // Empty $search yields LIKE '%%' (matches all rows), so the search clause is always
            // present. Group membership is resolved with a subquery on the group_authors table,
            // so every query is fully prepared with a fixed set of placeholders — nothing is
            // interpolated into the SQL. The column list and ORDER BY are hardcoded literals.
            $like           = '%' . $wpdb->esc_like( $search ) . '%';
            $table_articles = SCHOLAR_SYNC_DB_ARTICLES;

            $total_articles = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->prepare(
                    'SELECT COUNT(*) FROM %i WHERE author_id IN ( SELECT author_id FROM %i WHERE group_id = %d ) AND is_active = 1 AND ( title LIKE %s OR authors LIKE %s OR publication LIKE %s )',
                    $table_articles, $table_group_authors, $group_id, $like, $like, $like
                )
            );

            if ( $per_page > 0 ) {
                $limit  = $per_page;
                $offset = ( $page - 1 ) * $per_page;
                $pages  = $total_articles > 0 ? (int) ceil( $total_articles / $per_page ) : 1;
            } else {
                $limit  = PHP_INT_MAX; // 0 = return all rows (grid view / admin preview)
                $offset = 0;
                $pages  = 1;
            }

            if ( $sort_by === 'cited_by' ) {
                $articles = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->prepare(
                        'SELECT article_id, title, link, authors, publication, cited_by_value, year, image_url, all_data FROM %i WHERE author_id IN ( SELECT author_id FROM %i WHERE group_id = %d ) AND is_active = 1 AND ( title LIKE %s OR authors LIKE %s OR publication LIKE %s ) ORDER BY cited_by_value DESC, year DESC LIMIT %d OFFSET %d',
                        $table_articles, $table_group_authors, $group_id, $like, $like, $like, $limit, $offset
                    ),
                    ARRAY_A
                );
            } else {
                $articles = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->prepare(
                        'SELECT article_id, title, link, authors, publication, cited_by_value, year, image_url, all_data FROM %i WHERE author_id IN ( SELECT author_id FROM %i WHERE group_id = %d ) AND is_active = 1 AND ( title LIKE %s OR authors LIKE %s OR publication LIKE %s ) ORDER BY year DESC, cited_by_value DESC LIMIT %d OFFSET %d',
                        $table_articles, $table_group_authors, $group_id, $like, $like, $like, $limit, $offset
                    ),
                    ARRAY_A
                );
            }
            $articles = array_map( array( __CLASS__, 'shape_public_article' ), is_array( $articles ) ? $articles : array() );
        }

        // Build author_photos map: short_name → ['name', 'image_url'].
        // co_authors loaded first; wp_ssync_authors overwrites on collision (higher quality).
        $author_photos = array();

        $table_co_authors = SCHOLAR_SYNC_DB_CO_AUTHORS;
        $co_author_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT short_name, full_name AS name, image_url, profile_link FROM %i', $table_co_authors ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
            ARRAY_A
        );
        foreach ( $co_author_rows as $row ) {
            $entry = array(
                'name'         => $row['name'],
                'image_url'    => $row['image_url'] ?: null,
                'profile_link' => $row['profile_link'] ?: null,
            );
            if ( ! empty( $row['short_name'] ) ) {
                $author_photos[ $row['short_name'] ] = $entry;
            }
            if ( ! empty( $row['name'] ) ) {
                $author_photos[ $row['name'] ] = $entry;
            }
        }

        // Universal pass: every tracked/primary author overwrites the co_authors entry on
        // short_name collision, so a tracked author mentioned as a co-author on someone else's
        // article (or a custom author never touched by insert_co_authors()) still gets matched
        // even if they aren't a member of this group. Unfiltered SELECT mirrors the co_authors
        // query above. Overwritten below by the group-members overlay, which still wins.
        $table_all_authors = SCHOLAR_SYNC_DB_AUTHORS;
        $all_author_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( 'SELECT name, thumbnail, directory_link FROM %i', $table_all_authors ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
            ARRAY_A
        );
        foreach ( $all_author_rows as $row ) {
            $short = scholar_sync_generate_short_name( $row['name'] ?? '' );
            if ( $short === '' ) {
                continue;
            }
            // Don't regress a good co_authors photo to blank just because this tracked author
            // (e.g. a fresh custom author) has no thumbnail of their own yet.
            $existing_image = $author_photos[ $short ]['image_url'] ?? null;
            $entry = array(
                'name'         => $row['name'],
                'image_url'    => ! empty( $row['thumbnail'] ) ? $row['thumbnail'] : $existing_image,
                'profile_link' => ! empty( $row['directory_link'] ) ? $row['directory_link'] : null,
            );
            $author_photos[ $short ] = $entry;
            if ( ! empty( $row['name'] ) ) {
                $author_photos[ $row['name'] ] = $entry;
            }
        }

        foreach ( $authors as $author ) {
            $short_name = scholar_sync_generate_short_name( $author['name'] ?? '' );
            if ( $short_name !== '' ) {
                $author_photos[ $short_name ] = array(
                    'name'         => $author['name'] ?? '',
                    'image_url'    => $author['thumbnail'] ?: null,
                    'profile_link' => $author['effective_link'] ?: null,
                );
            }
        }

        return rest_ensure_response( array(
            'group'        => array(
                'group_id'  => (int) $group->group_id,
                'name'      => $group->name,
                'view_type' => $group->view_type,
                'settings'  => $settings,
            ),
            'authors'        => $authors,
            'articles'       => $articles,
            'total'          => $total_articles,
            'pages'          => $pages,
            'current_page'   => $page,
            'per_page'       => $per_page,
            'authors_total'  => $authors_total,
            'authors_page'   => $authors_page,
            'authors_pages'  => $authors_pages,
            'author_photos'  => $author_photos,
        ) );
    }

    public function add_user_option( WP_REST_Request $request ) {
        $allowed_options = array(
            'scholar_sync_license_key',
            'scholar_sync_sync_enabled',
            'scholar_sync_sync_interval',
            'scholar-sync-add-author-step1',
            'scholar-sync-add-author-step2',
        );
        $option = $request->get_param( 'option' );
        $value  = sanitize_text_field( (string) $request->get_param( 'value' ) );

        if ( ! in_array( $option, $allowed_options, true ) ) {
            return new WP_Error(
                'scholar_sync_invalid_option',
                __( 'Invalid option name.', 'scholar-sync' ),
                array( 'status' => 400 )
            );
        }

        update_option( $option, $value );

        $response = array (
            "all-good"  => 'yes'
        );
        return rest_ensure_response( $response );
    }

    public function get_today_sync_status( WP_REST_Request $request ) {
        global $wpdb;

        $author_id = absint( $request->get_param( 'author_id' ) );
        $today     = current_time( 'Y-m-d' );
        $table     = SCHOLAR_SYNC_DB_ARTICLES;

        $created_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
                'SELECT COUNT(*) FROM %i WHERE DATE(created) = %s AND DATE(last_updated) = %s AND author_id = %d',
                $table, $today, $today, $author_id
            )
        );

        $updated_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %i is the WordPress identifier placeholder (WP 6.2+)
                'SELECT COUNT(*) FROM %i WHERE DATE(last_updated) = %s AND DATE(created) <> %s AND author_id = %d',
                $table, $today, $today, $author_id
            )
        );

        return rest_ensure_response( array(
            'created_today' => $created_count,
            'updated_today' => $updated_count,
        ) );
    }
}
