<?php
/**
 * Admin operations centres for Nex Directory.
 *
 * The trait is deliberately self-contained so it can be registered by the main
 * plugin bootstrap without coupling operational reporting to frontend routes.
 *
 * @package NexDirectory
 */

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

trait NEXDIRECTORY_Operations_Centers_Trait {
    /**
     * Registers the operations-centre hooks once per request.
     *
     * @return void
     */
    public function register_operations_centers_hooks() {
        static $registered = false;

        if ( $registered ) {
            return;
        }

        $registered = true;
        add_action( 'admin_menu', array( $this, 'register_operations_centers_pages' ), 20 );
        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_operations_centers_assets' ), 20 );
        add_filter( 'rest_request_after_callbacks', array( $this, 'record_extension_route_activity' ), 10, 3 );
    }

    /**
     * Adds the two admin operations pages.
     *
     * @return void
     */
    public function register_operations_centers_pages() {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $attention = $this->get_operations_needs_attention_summary();
        $total     = isset( $attention['total'] ) ? max( 0, (int) $attention['total'] ) : 0;
        $menu_name = __( 'Needs Attention', 'nexdirectory' );

        if ( $total > 0 ) {
            $menu_name .= ' <span class="awaiting-mod count-' . $total . '"><span class="pending-count">' . $total . '</span></span>';
        }

        add_submenu_page(
            'nexdirectory-dashboard',
            __( 'Chrome Extension Connection Centre', 'nexdirectory' ),
            __( 'Chrome Extension', 'nexdirectory' ),
            'manage_options',
            'nexdirectory-extension-connection',
            array( $this, 'render_extension_connection_center_page' )
        );

        add_submenu_page(
            'nexdirectory-dashboard',
            __( 'Admin Needs Attention', 'nexdirectory' ),
            $menu_name,
            'manage_options',
            'nexdirectory-needs-attention',
            array( $this, 'render_needs_attention_center_page' )
        );
    }

    /**
     * Adds clipboard behaviour to the connection centre.
     *
     * This is a presentation-only enhancement. It does not change plugin or
     * WordPress state.
     *
     * @param string $hook_suffix Current admin screen hook.
     * @return void
     */
    public function enqueue_operations_centers_assets( $hook_suffix = '' ) {
        unset( $hook_suffix );

        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $page = isset( $_GET['page'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin screen routing.
            ? sanitize_key( (string) wp_unslash( $_GET['page'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin screen routing.
            : '';

        if ( $page !== 'nexdirectory-extension-connection' ) {
            return;
        }
        $asset_path = plugin_dir_path(NEXDIRECTORY_PLUGIN_FILE) . 'assets/js/nexdirectory-operations-centers.js';
        wp_enqueue_script(
            'nexdirectory-operations-centers',
            plugins_url('assets/js/nexdirectory-operations-centers.js', NEXDIRECTORY_PLUGIN_FILE),
            ['nexdirectory-admin'],
            is_readable($asset_path) ? (string) filemtime($asset_path) : NEXDIRECTORY_Plugin::VERSION,
            true
        );
    }

    /**
     * Records privacy-safe activity for successful extension REST requests.
     *
     * @param mixed $response REST callback response.
     * @param mixed $handler  Matched REST route handler.
     * @param mixed $request  REST request.
     * @return mixed
     */
    public function record_extension_route_activity( $response, $handler, $request ) {
        unset( $handler );

        if ( ! $request instanceof WP_REST_Request || strtoupper( (string) $request->get_method() ) !== 'POST' ) {
            return $response;
        }

        $route_map = array(
            '/nexdirectory/v1/extension/ping'        => 'ping',
            '/nexdirectory/v1/extension/import'      => 'import',
            '/nexdirectory/v1/extension/preferences' => 'preferences',
        );
        $route     = (string) $request->get_route();

        if ( ! isset( $route_map[ $route ] ) || is_wp_error( $response ) ) {
            return $response;
        }

        $rest_response = rest_ensure_response( $response );
        if (
            ! $rest_response instanceof WP_REST_Response
            || $rest_response->get_status() < 200
            || $rest_response->get_status() >= 300
        ) {
            return $response;
        }

        $route_key = $route_map[ $route ];
        $now_gmt   = current_time( 'mysql', true );
        $activity  = get_option( 'nexdirectory_extension_activity', array() );
        $activity  = is_array( $activity ) ? $activity : array();
        $activity  = array_intersect_key(
            $activity,
            array_flip(
                array(
                    'last_route',
                    'last_route_at',
                    'last_handshake_at',
                    'last_import_at',
                    'last_import_status',
                    'last_preferences_at',
                )
            )
        );

        $activity['last_route']    = $route_key;
        $activity['last_route_at'] = $now_gmt;

        if ( $route_key === 'ping' ) {
            $activity['last_handshake_at'] = $now_gmt;
        } elseif ( $route_key === 'preferences' ) {
            $activity['last_preferences_at'] = $now_gmt;
        } elseif ( $route_key === 'import' ) {
            $response_data                  = $rest_response->get_data();
            $response_data                  = is_array( $response_data ) ? $response_data : array();
            $import_status                  = ! empty( $response_data['skipped_duplicate'] )
                ? 'duplicate'
                : sanitize_key( (string) ( $response_data['status'] ?? 'imported' ) );
            $activity['last_import_at']     = $now_gmt;
            $activity['last_import_status'] = $import_status !== '' ? $import_status : 'imported';
        }

        update_option( 'nexdirectory_extension_activity', $activity, false );

        return $response;
    }

    /**
     * Renders the Chrome extension connection centre.
     *
     * @return void
     */
    public function render_extension_connection_center_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'nexdirectory' ) );
        }

        $store_url = defined( 'NEXDIRECTORY_CHROME_EXTENSION_URL' )
            ? (string) NEXDIRECTORY_CHROME_EXTENSION_URL
            : 'https://chromewebstore.google.com/detail/dnijcccaedhkbbekcbacgbhldmminpip?utm_source=item-share-cb';
        $activity  = get_option( 'nexdirectory_extension_activity', array() );
        $activity  = is_array( $activity ) ? $activity : array();
        $route_key = sanitize_key( (string) ( $activity['last_route'] ?? '' ) );
        $routes    = array(
            'ping'        => __( 'Handshake', 'nexdirectory' ),
            'import'      => __( 'Listing import', 'nexdirectory' ),
            'preferences' => __( 'Policy sync', 'nexdirectory' ),
        );

        $connection = array(
            'store_url'               => esc_url_raw( $store_url ),
            'settings_url'            => admin_url( 'admin.php?page=nexdirectory-settings#nexdirectory-tab-advanced--extension' ),
            'site_url'                => home_url( '/' ),
            'ping_url'                => rest_url( 'nexdirectory/v1/extension/ping' ),
            'import_url'              => rest_url( 'nexdirectory/v1/extension/import' ),
            'preferences_url'         => rest_url( 'nexdirectory/v1/extension/preferences' ),
            'enabled'                 => (string) get_option( 'nexdirectory_extension_enabled', '0' ) === '1',
            'configured'              => trim( (string) get_option( 'nexdirectory_extension_app_pass_hash', '' ) ) !== ''
                || trim( (string) get_option( 'nexdirectory_extension_app_pass', '' ) ) !== '',
            'app_pass'                => $this->get_extension_app_pass( false ),
            'regenerate_action'       => admin_url( 'admin-post.php' ),
            'regenerate_nonce_name'   => 'nexdirectory_regenerate_extension_app_pass_nonce',
            'regenerate_nonce'        => wp_create_nonce( 'nexdirectory_regenerate_extension_app_pass' ),
            'regenerate_form_id'      => 'nexdirectory-regenerate-extension-app-pass-form',
            'return_url'              => admin_url( 'admin.php?page=nexdirectory-extension-connection' ),
            'last_handshake_at'       => $this->operations_timestamp_for_display( $activity['last_handshake_at'] ?? '' ),
            'last_import_at'          => $this->operations_timestamp_for_display( $activity['last_import_at'] ?? '' ),
            'last_route_at'           => $this->operations_timestamp_for_display( $activity['last_route_at'] ?? '' ),
            'last_import_status'      => sanitize_key( (string) ( $activity['last_import_status'] ?? '' ) ),
            'last_route'              => isset( $routes[ $route_key ] ) ? $routes[ $route_key ] : '',
        );
        $markup     = NEXDIRECTORY_Escaping::prepare_markup_for_wp_kses(
            (string) $this->render_admin_template(
                'operations/chrome-extension.php',
                array( 'connection' => $connection )
            )
        );

        echo wp_kses( $markup, NEXDIRECTORY_Escaping::allowed_html( $markup ) );
    }

    /**
     * Renders the consolidated admin attention centre.
     *
     * @return void
     */
    public function render_needs_attention_center_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'nexdirectory' ) );
        }

        $markup = NEXDIRECTORY_Escaping::prepare_markup_for_wp_kses(
            (string) $this->render_admin_template(
                'operations/needs-attention.php',
                array( 'attention' => $this->get_operations_needs_attention_summary() )
            )
        );

        echo wp_kses( $markup, NEXDIRECTORY_Escaping::allowed_html( $markup ) );
    }

    /**
     * Returns a request- and user-scoped summary for the menu badge and page.
     *
     * @return array<string, mixed>
     */
    private function get_operations_needs_attention_summary() {
        static $request_cache = array();

        $user_id = get_current_user_id();
        if ( isset( $request_cache[ $user_id ] ) ) {
            return $request_cache[ $user_id ];
        }

        $transient_key = 'nexdirectory_ops_attention_v1_' . $user_id;
        $cached        = get_transient( $transient_key );
        if ( is_array( $cached ) && isset( $cached['total'], $cached['sections'] ) ) {
            $request_cache[ $user_id ] = $cached;
            return $cached;
        }

        $sections = array(
            $this->get_operations_configuration_health_section(),
            $this->get_operations_pending_listings_section(),
            $this->get_operations_pending_claims_section(),
            $this->get_operations_open_reports_section(),
            $this->get_operations_unread_messages_section( $user_id ),
            $this->get_operations_stale_team_invites_section(),
            $this->get_operations_waitlists_section(),
        );
        $sections = array_values(
            array_filter(
                $sections,
                static function ( $section ) {
                    return is_array( $section ) && ! empty( $section['count'] );
                }
            )
        );

        $priority_rank = array(
            'critical' => 0,
            'high'     => 1,
            'medium'   => 2,
            'low'      => 3,
        );
        usort(
            $sections,
            static function ( $left, $right ) use ( $priority_rank ) {
                $left_priority  = sanitize_key( (string) ( $left['priority'] ?? 'low' ) );
                $right_priority = sanitize_key( (string) ( $right['priority'] ?? 'low' ) );
                $left_rank      = $priority_rank[ $left_priority ] ?? 99;
                $right_rank     = $priority_rank[ $right_priority ] ?? 99;

                if ( $left_rank !== $right_rank ) {
                    return $left_rank <=> $right_rank;
                }

                $count_comparison = (int) ( $right['count'] ?? 0 ) <=> (int) ( $left['count'] ?? 0 );
                if ( $count_comparison !== 0 ) {
                    return $count_comparison;
                }

                return strcmp( (string) ( $left['key'] ?? '' ), (string) ( $right['key'] ?? '' ) );
            }
        );

        $total = 0;
        foreach ( $sections as $section ) {
            $total += max( 0, (int) ( $section['count'] ?? 0 ) );
        }

        $summary = array(
            'total'    => $total,
            'sections' => $sections,
        );

        set_transient( $transient_key, $summary, MINUTE_IN_SECONDS );
        $request_cache[ $user_id ] = $summary;

        return $summary;
    }

    /**
     * Returns the privacy-safe operational summary to trusted add-ons.
     *
     * The basic centre remains Free. This read-only bridge lets a licensed
     * add-on schedule digests without duplicating the queue and health logic.
     *
     * @return array<string, mixed>
     */
    public function get_operations_attention_summary() {
        return $this->get_operations_needs_attention_summary();
    }

    /**
     * Pending listing moderation summary.
     *
     * @return array<string, mixed>
     */
    private function get_operations_pending_listings_section() {
        $count = $this->get_pending_events_count();
        $url   = admin_url( 'admin.php?page=nexdirectory-moderation' );
        $items = array();

        if ( $count > 0 ) {
            $post_ids = get_posts(
                array(
                    'post_type'              => self::POST_TYPE,
                    'post_status'            => 'pending',
                    'posts_per_page'         => 5,
                    'orderby'                => 'date',
                    'order'                  => 'ASC',
                    'fields'                 => 'ids',
                    'no_found_rows'          => true,
                    'update_post_meta_cache' => false,
                    'update_post_term_cache' => false,
                )
            );

            foreach ( $post_ids as $post_id ) {
                $post_id = absint( (int) $post_id );
                $items[] = array(
                    'title'  => $this->operations_listing_title( $post_id ),
                    'meta'   => $this->operations_age_label( (string) get_post_field( 'post_date_gmt', $post_id ) ),
                    'url'    => $url,
                    'status' => __( 'Pending review', 'nexdirectory' ),
                );
            }
        }

        return array(
            'key'         => 'pending-listings',
            'label'       => __( 'Pending listings', 'nexdirectory' ),
            'description' => __( 'Review new submissions before they are published to the directory.', 'nexdirectory' ),
            'count'       => $count,
            'url'         => $url,
            'cta'         => __( 'Open moderation queue', 'nexdirectory' ),
            'priority'    => 'high',
            'items'       => $items,
        );
    }

    /**
     * Genuine pending claim requests, excluding terminal held comments.
     *
     * @return array<string, mixed>
     */
    private function get_operations_pending_claims_section() {
        $query = array(
            'type'       => 'nexdirectory_claim',
            'status'     => 'hold',
            'meta_query' => $this->operations_pending_claim_meta_query(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required to distinguish genuine pending claims from terminal held records.
        );
        $count = get_comments( array_merge( $query, array( 'count' => true ) ) );
        $count = is_numeric( $count ) ? max( 0, (int) $count ) : 0;
        $url   = admin_url( 'admin.php?page=nexdirectory-moderation' );
        $items = array();

        if ( $count > 0 ) {
            $claims = get_comments(
                array_merge(
                    $query,
                    array(
                        'number'  => 5,
                        'orderby' => 'comment_date_gmt',
                        'order'   => 'ASC',
                    )
                )
            );

            foreach ( $claims as $claim ) {
                if ( ! $claim instanceof WP_Comment ) {
                    continue;
                }
                $items[] = array(
                    'title'  => $this->operations_listing_title( (int) $claim->comment_post_ID ),
                    'meta'   => $this->operations_age_label( (string) $claim->comment_date_gmt ),
                    'url'    => $url,
                    'status' => __( 'Claim pending', 'nexdirectory' ),
                );
            }
        }

        return array(
            'key'         => 'pending-claims',
            'label'       => __( 'Pending ownership claims', 'nexdirectory' ),
            'description' => __( 'Confirm ownership requests without exposing claimant details in this overview.', 'nexdirectory' ),
            'count'       => $count,
            'url'         => $url,
            'cta'         => __( 'Review claims', 'nexdirectory' ),
            'priority'    => 'high',
            'items'       => $items,
        );
    }

    /**
     * Open user reports summary.
     *
     * @return array<string, mixed>
     */
    private function get_operations_open_reports_section() {
        $query = array(
            'type'   => 'nexdirectory_report',
            'status' => 'approve',
        );
        $count = get_comments( array_merge( $query, array( 'count' => true ) ) );
        $count = is_numeric( $count ) ? max( 0, (int) $count ) : 0;
        $url   = admin_url( 'admin.php?page=nexdirectory-moderation' );
        $items = array();

        if ( $count > 0 ) {
            $reports = get_comments(
                array_merge(
                    $query,
                    array(
                        'number'  => 5,
                        'orderby' => 'comment_date_gmt',
                        'order'   => 'ASC',
                    )
                )
            );

            foreach ( $reports as $report ) {
                if ( ! $report instanceof WP_Comment ) {
                    continue;
                }
                $items[] = array(
                    'title'  => $this->operations_listing_title( (int) $report->comment_post_ID ),
                    'meta'   => $this->operations_age_label( (string) $report->comment_date_gmt ),
                    'url'    => $url,
                    'status' => __( 'Report open', 'nexdirectory' ),
                );
            }
        }

        return array(
            'key'         => 'open-reports',
            'label'       => __( 'Open reports', 'nexdirectory' ),
            'description' => __( 'Review unresolved listing reports in the guarded moderation screen.', 'nexdirectory' ),
            'count'       => $count,
            'url'         => $url,
            'cta'         => __( 'Review reports', 'nexdirectory' ),
            'priority'    => 'high',
            'items'       => $items,
        );
    }

    /**
     * Current administrator unread inbox summary.
     *
     * @param int $user_id Current administrator ID.
     * @return array<string, mixed>
     */
    private function get_operations_unread_messages_section( $user_id ) {
        global $wpdb;

        $user_id   = absint( (int) $user_id );
        $table     = $wpdb->prefix . self::INBOX_MESSAGES_TABLE;
        $base_url  = $this->get_notifications_page_url();
        $inbox_url = nexdirectory_add_query_arg( 'nexdirectory_tab', 'inbox', $base_url ) . '#inbox';
        $count     = 0;
        $items     = array();

        if ( $user_id > 0 && $this->operations_table_exists( $table ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; user value is prepared and the result is cached in a short-lived transient.
            $count = (int) $wpdb->get_var(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The custom table name is validated before this prepared query.
                    "SELECT COUNT(*) FROM {$table} WHERE recipient_id = %d AND read_at IS NULL",
                    $user_id
                )
            );

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; user value is prepared and only privacy-safe identifiers are selected.
            $messages = $wpdb->get_results(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The custom table name is validated before this prepared query.
                    "SELECT id, thread_id, post_id, created_at FROM {$table} WHERE recipient_id = %d AND read_at IS NULL ORDER BY created_at ASC LIMIT 5",
                    $user_id
                )
            );

            foreach ( $messages as $message ) {
                $post_id    = absint( (int) ( $message->post_id ?? 0 ) );
                $message_id = absint( (int) ( $message->id ?? 0 ) );
                $thread_id  = absint( (int) ( $message->thread_id ?? 0 ) );
                $items[]    = array(
                    'title'  => sprintf(
                        /* translators: %s: Listing title. */
                        __( 'Message about %s', 'nexdirectory' ),
                        $this->operations_listing_title( $post_id )
                    ),
                    'meta'   => $this->operations_age_label( (string) ( $message->created_at ?? '' ) ),
                    'url'    => $this->build_inbox_thread_url( $thread_id, $message_id ),
                    'status' => __( 'Unread', 'nexdirectory' ),
                );
            }
        }

        return array(
            'key'         => 'unread-messages',
            'label'       => __( 'Your unread messages', 'nexdirectory' ),
            'description' => __( 'Only messages addressed to the current administrator are counted; message content stays in the inbox.', 'nexdirectory' ),
            'count'       => max( 0, $count ),
            'url'         => $inbox_url,
            'cta'         => __( 'Open inbox', 'nexdirectory' ),
            'priority'    => 'medium',
            'items'       => $items,
        );
    }

    /**
     * Team invitations that remain unaccepted beyond the stale threshold.
     *
     * @return array<string, mixed>
     */
    private function get_operations_stale_team_invites_section() {
        global $wpdb;

        $stale_days = (int) apply_filters( 'nexdirectory_operations_stale_invite_days', 7 );
        $stale_days = min( 90, max( 1, $stale_days ) );
        $threshold  = current_time( 'timestamp', true ) - ( $stale_days * DAY_IN_SECONDS );
        $count      = 0;
        $groups     = array();

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Trusted core table names and prepared values; result is cached in a short-lived transient.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT pm.post_id, pm.meta_value, p.post_title
                FROM {$wpdb->postmeta} pm
                INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
                WHERE pm.meta_key = %s
                    AND p.post_type = %s
                    AND p.post_status NOT IN ('trash', 'auto-draft')",
                'nexdirectory_event_team_members',
                self::POST_TYPE
            ),
            ARRAY_A
        );

        foreach ( $rows as $row ) {
            $post_id = absint( (int) ( $row['post_id'] ?? 0 ) );
            $members = maybe_unserialize( $row['meta_value'] ?? '' );

            if ( is_string( $members ) && $members !== '' ) {
                $decoded = json_decode( $members, true );
                $members = is_array( $decoded ) ? $decoded : array();
            }
            if ( ! is_array( $members ) ) {
                continue;
            }

            foreach ( $members as $member ) {
                if ( ! is_array( $member ) || sanitize_key( (string) ( $member['status'] ?? '' ) ) !== 'invited' ) {
                    continue;
                }

                $invited_at = $this->operations_parse_gmt_timestamp( (string) ( $member['invited_at'] ?? '' ) );
                if ( $invited_at <= 0 || $invited_at > $threshold ) {
                    continue;
                }

                ++$count;
                if ( ! isset( $groups[ $post_id ] ) ) {
                    $groups[ $post_id ] = array(
                        'post_id'   => $post_id,
                        'title'     => sanitize_text_field( (string) ( $row['post_title'] ?? '' ) ),
                        'count'     => 0,
                        'oldest_at' => $invited_at,
                    );
                }
                ++$groups[ $post_id ]['count'];
                $groups[ $post_id ]['oldest_at'] = min( $groups[ $post_id ]['oldest_at'], $invited_at );
            }
        }

        usort(
            $groups,
            static function ( $left, $right ) {
                $count_compare = (int) $right['count'] <=> (int) $left['count'];
                return $count_compare !== 0 ? $count_compare : (int) $left['oldest_at'] <=> (int) $right['oldest_at'];
            }
        );

        $items = array();
        foreach ( array_slice( $groups, 0, 5 ) as $group ) {
            $invite_count = max( 1, (int) $group['count'] );
            $post_id      = absint( (int) $group['post_id'] );
            $items[]      = array(
                'title'  => $group['title'] !== '' ? $group['title'] : $this->operations_listing_title( $post_id ),
                'meta'   => sprintf(
                    /* translators: 1: Invitation count. 2: Invitation age. */
                    _n( '%1$d invitation; oldest %2$s', '%1$d invitations; oldest %2$s', $invite_count, 'nexdirectory' ),
                    $invite_count,
                    $this->operations_relative_age_label( (int) $group['oldest_at'] )
                ),
                'url'    => $this->get_default_team_redirect_url( $post_id ),
                'status' => __( 'Awaiting response', 'nexdirectory' ),
            );
        }

        return array(
            'key'         => 'stale-team-invites',
            'label'       => __( 'Stale team invitations', 'nexdirectory' ),
            'description' => sprintf(
                /* translators: %d: Number of days. */
                __( 'Follow up on team invitations still awaiting a response after %d days.', 'nexdirectory' ),
                $stale_days
            ),
            'count'       => $count,
            'url'         => $this->get_default_team_redirect_url(),
            'cta'         => __( 'Manage team access', 'nexdirectory' ),
            'priority'    => 'medium',
            'items'       => $items,
        );
    }

    /**
     * Current event waitlists summary.
     *
     * @return array<string, mixed>
     */
    private function get_operations_waitlists_section() {
        global $wpdb;

        $table = $wpdb->prefix . self::RSVP_TABLE;
        $count = 0;
        $items = array();
        $url   = $this->operations_listings_workspace_url();

        if ( $this->operations_table_exists( $table ) ) {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The custom table name is validated before this prepared query.
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table and trusted core posts table; values are prepared and result is transient-cached.
            $waitlists = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT r.post_id, COUNT(*) AS total, MIN(r.created_at) AS oldest_at, p.post_title
                    FROM {$table} r
                    INNER JOIN {$wpdb->posts} p ON p.ID = r.post_id
                    WHERE r.status = %s
                        AND p.post_type = %s
                        AND p.post_status NOT IN ('trash', 'auto-draft')
                    GROUP BY r.post_id, p.post_title
                    ORDER BY total DESC, oldest_at ASC",
                    'waitlist',
                    self::POST_TYPE
                ),
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            foreach ( $waitlists as $index => $waitlist ) {
                $waitlist_count = max( 0, (int) ( $waitlist['total'] ?? 0 ) );
                $post_id        = absint( (int) ( $waitlist['post_id'] ?? 0 ) );
                $count         += $waitlist_count;

                if ( $index >= 5 ) {
                    continue;
                }

                $attendees_url = nexdirectory_add_query_arg(
                    array(
                        'nexdirectory_tab' => 'listings',
                        'attendees_event'  => $post_id,
                    ),
                    $url
                ) . '#listings';
                $items[]       = array(
                    'title'  => sanitize_text_field( (string) ( $waitlist['post_title'] ?? '' ) ) ?: $this->operations_listing_title( $post_id ),
                    'meta'   => sprintf(
                        /* translators: 1: Waitlist count. 2: Age of oldest waitlist entry. */
                        _n( '%1$d person; oldest %2$s', '%1$d people; oldest %2$s', $waitlist_count, 'nexdirectory' ),
                        $waitlist_count,
                        $this->operations_age_label( (string) ( $waitlist['oldest_at'] ?? '' ) )
                    ),
                    'url'    => $attendees_url,
                    'status' => __( 'Waitlisted', 'nexdirectory' ),
                );
            }
        }

        return array(
            'key'         => 'waitlists',
            'label'       => __( 'Listing waitlists', 'nexdirectory' ),
            'description' => __( 'See which listings have people waiting for capacity to become available.', 'nexdirectory' ),
            'count'       => $count,
            'url'         => $url,
            'cta'         => __( 'Open listing workspace', 'nexdirectory' ),
            'priority'    => 'medium',
            'items'       => $items,
        );
    }

    /**
     * Checks critical configuration dependencies without exposing values.
     *
     * @return array<string, mixed>
     */
    private function get_operations_configuration_health_section() {
        $settings_base = admin_url( 'admin.php?page=nexdirectory-settings' );
        $extension_url = $settings_base . '#nexdirectory-tab-advanced--extension';
        $email_url     = $settings_base . '#nexdirectory-tab-advanced--email';
        $api_url       = $settings_base . '#nexdirectory-tab-advanced--apis';
        $maps_url      = $settings_base . '#nexdirectory-tab-design-maps--maps';
        $items         = array();

        $extension_enabled = (string) get_option( 'nexdirectory_extension_enabled', '0' ) === '1';
        $extension_ready   = trim( (string) get_option( 'nexdirectory_extension_app_pass_hash', '' ) ) !== ''
            || trim( (string) get_option( 'nexdirectory_extension_app_pass', '' ) ) !== '';
        if ( $extension_enabled && ! $extension_ready ) {
            $items[] = array(
                'title'  => __( 'Chrome Extension credential missing', 'nexdirectory' ),
                'meta'   => __( 'The connector is enabled but has no connection key.', 'nexdirectory' ),
                'url'    => $extension_url,
                'status' => __( 'Action required', 'nexdirectory' ),
            );
        }

        $map_provider = sanitize_key( (string) get_option( 'nexdirectory_map_provider', 'openstreetmap' ) );
        if ( $map_provider === 'google' && trim( (string) get_option( 'nexdirectory_google_maps_api_key', '' ) ) === '' ) {
            $items[] = array(
                'title'  => __( 'Google Maps key missing', 'nexdirectory' ),
                'meta'   => __( 'Google is selected as the map provider.', 'nexdirectory' ),
                'url'    => $maps_url,
                'status' => __( 'Action required', 'nexdirectory' ),
            );
        } elseif ( $map_provider === 'mapbox' && trim( (string) get_option( 'nexdirectory_mapbox_access_token', '' ) ) === '' ) {
            $items[] = array(
                'title'  => __( 'Mapbox token missing', 'nexdirectory' ),
                'meta'   => __( 'Mapbox is selected as the map provider.', 'nexdirectory' ),
                'url'    => $maps_url,
                'status' => __( 'Action required', 'nexdirectory' ),
            );
        }

        if (
            sanitize_key( (string) get_option( 'nexdirectory_map_style', 'osm-standard' ) ) === 'custom'
            && trim( (string) get_option( 'nexdirectory_map_custom_tile_url', '' ) ) === ''
        ) {
            $items[] = array(
                'title'  => __( 'Custom map tile URL missing', 'nexdirectory' ),
                'meta'   => __( 'The custom map style needs a tile URL.', 'nexdirectory' ),
                'url'    => $maps_url,
                'status' => __( 'Action required', 'nexdirectory' ),
            );
        }

        if ( (string) get_option( 'nexdirectory_smtp_enabled', '0' ) === '1' ) {
            if ( trim( (string) get_option( 'nexdirectory_smtp_host', '' ) ) === '' ) {
                $items[] = array(
                    'title'  => __( 'SMTP host missing', 'nexdirectory' ),
                    'meta'   => __( 'SMTP delivery is enabled but has no server host.', 'nexdirectory' ),
                    'url'    => $email_url,
                    'status' => __( 'Action required', 'nexdirectory' ),
                );
            }

            $from_email = trim( (string) get_option( 'nexdirectory_smtp_from_email', '' ) );
            if ( $from_email !== '' && ! is_email( $from_email ) ) {
                $items[] = array(
                    'title'  => __( 'SMTP sender email is invalid', 'nexdirectory' ),
                    'meta'   => __( 'Correct the configured sender address before relying on SMTP.', 'nexdirectory' ),
                    'url'    => $email_url,
                    'status' => __( 'Action required', 'nexdirectory' ),
                );
            }
        }

        if ( (string) get_option( 'nexdirectory_recaptcha_show_submit', '0' ) === '1' ) {
            $captcha_ready = trim( (string) get_option( 'nexdirectory_recaptcha_site_key', '' ) ) !== ''
                && trim( (string) get_option( 'nexdirectory_recaptcha_secret_key', '' ) ) !== '';
            if ( ! $captcha_ready ) {
                $items[] = array(
                    'title'  => __( 'reCAPTCHA keys incomplete', 'nexdirectory' ),
                    'meta'   => __( 'Submission protection is enabled but one or more keys are missing.', 'nexdirectory' ),
                    'url'    => $api_url,
                    'status' => __( 'Action required', 'nexdirectory' ),
                );
            }
        }

        return array(
            'key'         => 'configuration-health',
            'label'       => __( 'Configuration health', 'nexdirectory' ),
            'description' => __( 'Resolve enabled features that are missing a required connection detail.', 'nexdirectory' ),
            'count'       => count( $items ),
            'url'         => $settings_base,
            'cta'         => __( 'Open settings', 'nexdirectory' ),
            'priority'    => 'critical',
            'items'       => $items,
        );
    }

    /**
     * Pending claim meta query shared by count and preview lookups.
     *
     * @return array<int|string, mixed>
     */
    private function operations_pending_claim_meta_query() {
        return array(
            'relation' => 'OR',
            array(
                'key'     => 'nexdirectory_claim_status',
                'value'   => 'pending',
                'compare' => '=',
            ),
            array(
                'key'     => 'nexdirectory_claim_status',
                'compare' => 'NOT EXISTS',
            ),
        );
    }

    /**
     * Confirms that a custom plugin table exists.
     *
     * @param string $table Full prefixed table name.
     * @return bool
     */
    private function operations_table_exists( $table ) {
        global $wpdb;

        $table = (string) $table;
        if ( $table === '' || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
            return false;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema presence check for optional custom plugin tables.
        $found = $wpdb->get_var(
            $wpdb->prepare(
                'SHOW TABLES LIKE %s',
                $wpdb->esc_like( $table )
            )
        );

        return is_string( $found ) && hash_equals( $table, $found );
    }

    /**
     * Gets a privacy-safe listing title.
     *
     * @param int $post_id Listing ID.
     * @return string
     */
    private function operations_listing_title( $post_id ) {
        $post_id = absint( (int) $post_id );
        $title   = $post_id > 0 ? sanitize_text_field( (string) get_the_title( $post_id ) ) : '';

        return $title !== '' ? $title : __( 'Listing unavailable', 'nexdirectory' );
    }

    /**
     * Produces a relative age label from a stored GMT datetime.
     *
     * @param string $value GMT datetime.
     * @return string
     */
    private function operations_age_label( $value ) {
        $timestamp = $this->operations_parse_gmt_timestamp( $value );
        if ( $timestamp <= 0 ) {
            return __( 'Date unavailable', 'nexdirectory' );
        }

        return $this->operations_relative_age_label( $timestamp );
    }

    /**
     * Produces a translated relative age label from a GMT timestamp.
     *
     * @param int $timestamp GMT timestamp.
     * @return string
     */
    private function operations_relative_age_label( $timestamp ) {
        $timestamp = absint( (int) $timestamp );
        if ( $timestamp <= 0 ) {
            return __( 'Date unavailable', 'nexdirectory' );
        }

        return sprintf(
            /* translators: %s: Human-readable time difference. */
            _x( '%s ago', 'human-readable time difference', 'nexdirectory' ),
            human_time_diff( $timestamp, current_time( 'timestamp', true ) )
        );
    }

    /**
     * Parses a plugin-stored GMT datetime without treating it as site-local time.
     *
     * @param string $value Datetime string.
     * @return int
     */
    private function operations_parse_gmt_timestamp( $value ) {
        $value = trim( sanitize_text_field( (string) $value ) );
        if ( $value === '' ) {
            return 0;
        }

        $timestamp = strtotime( $value . ' UTC' );
        return $timestamp === false ? 0 : (int) $timestamp;
    }

    /**
     * Converts a stored GMT datetime into a timestamp for date_i18n().
     *
     * @param string $value GMT datetime.
     * @return int|string
     */
    private function operations_timestamp_for_display( $value ) {
        $timestamp = $this->operations_parse_gmt_timestamp( (string) $value );
        return $timestamp > 0 ? $timestamp : '';
    }

    /**
     * Returns the guarded frontend listing workspace URL.
     *
     * @return string
     */
    private function operations_listings_workspace_url() {
        $url = (string) $this->get_platform_page_url(
            'nexdirectory_page_my_events_url',
            'my_items',
            home_url( '/my-events/' )
        );
        if ( $url === '' ) {
            $url = home_url( '/my-events/' );
        }

        return esc_url_raw( $url );
    }
}
