<?php
/**
 * WordPress privacy-tools integration for Nex Directory.
 *
 * Premium transactions, check-ins, team records, private calendars and Event
 * Web App activity are deliberately handled by the installed Pro add-on.
 *
 * @package NexDirectory
 */

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

final class NEXDIRECTORY_Privacy_Service {
    const BATCH_SIZE = 100;

    /**
     * Registers WordPress privacy hooks.
     *
     * @return void
     */
    public static function register_hooks() {
        add_action( 'admin_init', [ __CLASS__, 'add_privacy_policy_content' ] );
        add_filter( 'wp_privacy_personal_data_exporters', [ __CLASS__, 'register_exporter' ] );
        add_filter( 'wp_privacy_personal_data_erasers', [ __CLASS__, 'register_eraser' ] );
    }

    /**
     * Adds suggested Free policy text.
     *
     * @return void
     */
    public static function add_privacy_policy_content() {
        if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
            return;
        }

        $content  = '<p>' . esc_html__( 'Nex Directory can store account and organizer-profile preferences, consent and visibility choices, saved filters, verification state, directory submissions, RSVPs and waitlist activity, private messages, account notifications, analytics events, and bounded diagnostic runtime logs.', 'nexdirectory' ) . '</p>';
        $content .= '<p>' . esc_html__( 'Records can include a WordPress user ID, public profile details selected by the member, email-linked account data, listing contact details, message content and privately stored attachments, RSVP status, calendar-subscription preferences, and timestamps.', 'nexdirectory' ) . '</p>';
        $content .= '<p>' . esc_html__( 'Optional Free features can send data to configured map, tile, geocoding, reCAPTCHA, YouTube, Vimeo, or Google Docs services. A clicked Google Calendar link sends listing title, dates, description, and location; clicked Facebook, X, LinkedIn, or WhatsApp controls send the public listing URL and, where supported, its title. The active provider, site configuration, and visitor action determine what is sent.', 'nexdirectory' ) . '</p>';
        $content .= '<p>' . esc_html__( 'Approved erasure requests remove Free preferences, RSVPs, analytics records, and private files owned by the requester; anonymize or unlink private-message records; and anonymize matching identifiers in diagnostic logs. Published directory content and non-identifying operational records may be retained when needed for site operations or legal obligations.', 'nexdirectory' ) . '</p>';

        wp_add_privacy_policy_content(
            esc_html__( 'Nex Directory', 'nexdirectory' ),
            wp_kses_post( $content )
        );
    }

    /**
     * @param array<string, array<string, mixed>> $exporters Existing exporters.
     * @return array<string, array<string, mixed>>
     */
    public static function register_exporter( $exporters ) {
        $exporters = is_array( $exporters ) ? $exporters : [];
        $exporters['nexdirectory'] = [
            'exporter_friendly_name' => __( 'Nex Directory', 'nexdirectory' ),
            'callback'               => [ __CLASS__, 'export_personal_data' ],
        ];
        return $exporters;
    }

    /**
     * @param array<string, array<string, mixed>> $erasers Existing erasers.
     * @return array<string, array<string, mixed>>
     */
    public static function register_eraser( $erasers ) {
        $erasers = is_array( $erasers ) ? $erasers : [];
        $erasers['nexdirectory'] = [
            'eraser_friendly_name' => __( 'Nex Directory', 'nexdirectory' ),
            'callback'             => [ __CLASS__, 'erase_personal_data' ],
        ];
        return $erasers;
    }

    /**
     * Exports Free-owned personal data.
     *
     * @param string $email_address Request email.
     * @param int    $page          Page number.
     * @return array<string, mixed>
     */
    public static function export_personal_data( $email_address, $page = 1 ) {
        $email_address = sanitize_email( (string) $email_address );
        $page          = max( 1, absint( (int) $page ) );
        $user          = $email_address !== '' ? get_user_by( 'email', $email_address ) : false;
        if ( ! $user instanceof WP_User ) {
            return [ 'data' => [], 'done' => true ];
        }

        global $wpdb;
        $user_id = absint( (int) $user->ID );
        $offset  = ( $page - 1 ) * self::BATCH_SIZE;
        $data    = [];
        $done    = true;

        if ( 1 === $page ) {
            $profile = self::export_free_user_meta( $user_id );
            if ( ! empty( $profile ) ) {
                $data[] = [
                    'group_id'    => 'nexdirectory-profile',
                    'group_label' => __( 'Nex Directory preferences', 'nexdirectory' ),
                    'item_id'     => 'nexdirectory-profile-' . $user_id,
                    'data'        => $profile,
                ];
            }
        }

        $done = self::export_listings( $data, $user_id, $offset, $done );
        $done = self::export_rsvps( $data, $user_id, $offset, $done );
        $done = self::export_messages( $data, $user_id, $offset, $done );
        $done = self::export_notifications( $data, $user_id, $offset, $done );
        $done = self::export_analytics( $data, $user_id, $offset, $done );
        $done = self::export_private_files( $data, $user_id, $offset, $done );
        $done = self::export_runtime_logs( $data, $user_id, $email_address, $offset, $done );

        return [ 'data' => $data, 'done' => (bool) $done ];
    }

    /**
     * Erases or anonymizes Free-owned personal data.
     *
     * @param string $email_address Request email.
     * @param int    $page          Page number.
     * @return array<string, mixed>
     */
    public static function erase_personal_data( $email_address, $page = 1 ) {
        $email_address = sanitize_email( (string) $email_address );
        $page          = max( 1, absint( (int) $page ) );
        $user          = $email_address !== '' ? get_user_by( 'email', $email_address ) : false;
        if ( ! $user instanceof WP_User || $page > 1 ) {
            return [
                'items_removed'  => false,
                'items_retained' => false,
                'messages'       => [],
                'done'           => true,
            ];
        }

        global $wpdb;
        $user_id       = absint( (int) $user->ID );
        $items_removed = false;
        $items_retained= false;
        $messages      = [];

        foreach ( array_keys( (array) get_user_meta( $user_id ) ) as $meta_key ) {
            if ( self::is_free_user_meta_key( (string) $meta_key ) && delete_user_meta( $user_id, (string) $meta_key ) ) {
                $items_removed = true;
            }
        }

        $rsvp_table = $wpdb->prefix . 'nexdirectory_rsvps';
        if ( self::table_exists( $rsvp_table ) ) {
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; privacy exports are request-specific personal data and must not be cached.
            $event_ids = $wpdb->get_col(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                    "SELECT DISTINCT post_id FROM {$rsvp_table} WHERE user_id = %d",
                    $user_id
                )
            ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy erasure writes directly to the plugin-owned RSVP table and cannot use a cached result.
            $removed = $wpdb->delete( $rsvp_table, [ 'user_id' => $user_id ], [ '%d' ] );
            $items_removed = $items_removed || ( is_int( $removed ) && $removed > 0 );
            foreach ( array_values( array_unique( array_filter( array_map( 'absint', (array) $event_ids ) ) ) ) as $event_id ) {
                foreach ( [ 'nexdirectory_rsvp_yes', 'nexdirectory_waitlist' ] as $mirror_key ) {
                    $before = array_values( array_filter( array_map( 'absint', (array) get_post_meta( $event_id, $mirror_key, true ) ) ) );
                    $after  = array_values( array_diff( $before, [ $user_id ] ) );
                    if ( $after !== $before ) {
                        update_post_meta( $event_id, $mirror_key, $after );
                        $items_removed = true;
                    }
                }
            }
        }

        $analytics_table = $wpdb->prefix . 'nexdirectory_event_analytics';
        if ( self::table_exists( $analytics_table ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy erasure writes directly to the plugin-owned analytics table and cannot use a cached result.
            $removed = $wpdb->delete( $analytics_table, [ 'user_id' => $user_id ], [ '%d' ] );
            $items_removed = $items_removed || ( is_int( $removed ) && $removed > 0 );
        }

        $notifications_table = $wpdb->prefix . 'nexdirectory_notifications';
        if ( self::table_exists( $notifications_table ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy erasure writes directly to the plugin-owned notification table and cannot use a cached result.
            $removed = $wpdb->delete( $notifications_table, [ 'recipient_id' => $user_id ], [ '%d' ] );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy anonymization writes directly to the plugin-owned notification table and cannot use a cached result.
            $anonymized = $wpdb->update(
                $notifications_table,
                [ 'actor_id' => 0 ],
                [ 'actor_id' => $user_id ],
                [ '%d' ],
                [ '%d' ]
            );
            $items_removed = $items_removed
                || ( is_int( $removed ) && $removed > 0 )
                || ( is_int( $anonymized ) && $anonymized > 0 );
        }

        self::erase_private_files( $user_id, $items_removed );
        if ( self::erase_messages( $user_id ) ) {
            $items_removed  = true;
            $items_retained = true;
            $messages[] = __( 'Private-message records from other participants were retained after unlinking your account. Content you sent and your sender or recipient identifiers were erased.', 'nexdirectory' );
        }
        self::erase_runtime_logs( $user_id, $email_address, $items_removed, $items_retained, $messages );

        if ( count_user_posts( $user_id, 'nexdirectory_event', true ) > 0 ) {
            $items_retained = true;
            $messages[] = __( 'Published directory content was retained. A site administrator can review, anonymize, or remove it separately.', 'nexdirectory' );
        }

        return [
            'items_removed'  => (bool) $items_removed,
            'items_retained' => (bool) $items_retained,
            'messages'       => array_values( array_unique( array_filter( array_map( 'sanitize_text_field', $messages ) ) ) ),
            'done'           => true,
        ];
    }

    /**
     * @param int $user_id User ID.
     * @return array<int, array<string, string>>
     */
    private static function export_free_user_meta( $user_id ) {
        $data = [];
        foreach ( (array) get_user_meta( $user_id ) as $meta_key => $values ) {
            if ( ! self::is_free_user_meta_key( (string) $meta_key ) || preg_match( '/(?:token|pass|secret|nonce|pin)/i', (string) $meta_key ) ) {
                continue;
            }
            $value = maybe_unserialize( is_array( $values ) ? ( $values[0] ?? '' ) : $values );
            if ( is_array( $value ) || is_object( $value ) ) {
                $value = wp_json_encode( $value );
            }
            $data[] = [
                'name'  => sanitize_text_field( str_replace( '_', ' ', (string) $meta_key ) ),
                'value' => sanitize_textarea_field( (string) $value ),
            ];
        }
        return $data;
    }

    /**
     * Separates Free-owned preferences from Pro-only state.
     *
     * @param string $meta_key Meta key.
     * @return bool
     */
    private static function is_free_user_meta_key( $meta_key ) {
        $meta_key = sanitize_key( (string) $meta_key );
        if ( strpos( $meta_key, 'nexdirectory_' ) !== 0 ) {
            return false;
        }
        foreach ( [
            'nexdirectory_pro_',
            'nexdirectory_ticket',
            'nexdirectory_boost',
            'nexdirectory_event_webapp',
            'nexdirectory_webapp',
            'nexdirectory_event_team',
            'nexdirectory_attending_calendar',
            'nexdirectory_pwa',
            'nexdirectory_push',
            'nexdirectory_white_label',
        ] as $premium_prefix ) {
            if ( strpos( $meta_key, $premium_prefix ) === 0 ) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int  $user_id User ID.
     * @param int  $offset Offset.
     * @param bool $done Current state.
     * @return bool
     */
    private static function export_listings( array &$data, $user_id, $offset, $done ) {
        $query = new WP_Query(
            [
                'author'              => absint( $user_id ),
                'post_type'           => 'nexdirectory_event',
                'post_status'         => [ 'publish', 'pending', 'draft', 'private', 'future', 'trash' ],
                'posts_per_page'      => self::BATCH_SIZE,
                'offset'              => max( 0, absint( $offset ) ),
                'orderby'             => 'ID',
                'order'               => 'ASC',
                'ignore_sticky_posts' => true,
                'no_found_rows'       => true,
            ]
        );
        $posts = is_array( $query->posts ) ? $query->posts : [];
        $meta_labels = [
            'nexdirectory_listing_profile' => __( 'Listing profile', 'nexdirectory' ),
            'nexdirectory_start'           => __( 'Start', 'nexdirectory' ),
            'nexdirectory_end'             => __( 'End', 'nexdirectory' ),
            'nexdirectory_timezone'        => __( 'Timezone', 'nexdirectory' ),
            'nexdirectory_venue'           => __( 'Venue', 'nexdirectory' ),
            'nexdirectory_company_name'    => __( 'Company name', 'nexdirectory' ),
            'nexdirectory_address'         => __( 'Address', 'nexdirectory' ),
            'nexdirectory_city'            => __( 'City', 'nexdirectory' ),
            'nexdirectory_country'         => __( 'Country', 'nexdirectory' ),
            'nexdirectory_contact_name'    => __( 'Contact name', 'nexdirectory' ),
            'nexdirectory_contact_email'   => __( 'Contact email', 'nexdirectory' ),
            'nexdirectory_contact_phone'   => __( 'Contact phone', 'nexdirectory' ),
            'nexdirectory_external_url'    => __( 'External URL', 'nexdirectory' ),
            'nexdirectory_capacity'        => __( 'Capacity', 'nexdirectory' ),
            'nexdirectory_price'           => __( 'Price', 'nexdirectory' ),
            'nexdirectory_gallery_urls'    => __( 'Gallery URLs', 'nexdirectory' ),
            'nexdirectory_video_urls'      => __( 'Video URLs', 'nexdirectory' ),
            'nexdirectory_supporting_docs' => __( 'Supporting documents', 'nexdirectory' ),
        ];
        foreach ( $posts as $post ) {
            if ( ! $post instanceof WP_Post ) {
                continue;
            }
            $item = [
                [ 'name' => __( 'Title', 'nexdirectory' ), 'value' => sanitize_text_field( (string) $post->post_title ) ],
                [ 'name' => __( 'Status', 'nexdirectory' ), 'value' => sanitize_key( (string) $post->post_status ) ],
                [ 'name' => __( 'Content', 'nexdirectory' ), 'value' => sanitize_textarea_field( wp_strip_all_tags( (string) $post->post_content ) ) ],
                [ 'name' => __( 'Created', 'nexdirectory' ), 'value' => sanitize_text_field( (string) $post->post_date_gmt ) ],
            ];
            foreach ( $meta_labels as $meta_key => $label ) {
                $value = get_post_meta( $post->ID, $meta_key, true );
                if ( $value === '' || $value === [] ) {
                    continue;
                }
                $item[] = [
                    'name'  => $label,
                    'value' => sanitize_textarea_field( is_scalar( $value ) ? (string) $value : (string) wp_json_encode( $value ) ),
                ];
            }
            $data[] = [
                'group_id'    => 'nexdirectory-listings',
                'group_label' => __( 'Nex Directory submissions', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-listing-' . absint( $post->ID ),
                'data'        => $item,
            ];
        }
        wp_reset_postdata();
        return (bool) ( $done && count( $posts ) < self::BATCH_SIZE );
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int  $user_id User ID.
     * @param int  $offset Offset.
     * @param bool $done Current state.
     * @return bool
     */
    private static function export_rsvps( array &$data, $user_id, $offset, $done ) {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_rsvps';
        if ( ! self::table_exists( $table ) ) {
            return (bool) $done;
        }
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; privacy exports are request-specific personal data and must not be cached.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                "SELECT id, post_id, status, created_at, updated_at FROM {$table} WHERE user_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
                $user_id,
                self::BATCH_SIZE,
                $offset
            ),
            ARRAY_A
        ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ( (array) $rows as $row ) {
            $data[] = [
                'group_id'    => 'nexdirectory-rsvps',
                'group_label' => __( 'Nex Directory RSVPs', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-rsvp-' . absint( $row['id'] ?? 0 ),
                'data'        => self::row_to_export_data( $row, [ 'id' ], absint( $row['post_id'] ?? 0 ) ),
            ];
        }
        return (bool) ( $done && count( (array) $rows ) < self::BATCH_SIZE );
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int  $user_id User ID.
     * @param int  $offset Offset.
     * @param bool $done Current state.
     * @return bool
     */
    private static function export_messages( array &$data, $user_id, $offset, $done ) {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_inbox_messages';
        if ( ! self::table_exists( $table ) ) {
            return (bool) $done;
        }
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; privacy exports are request-specific personal data and must not be cached.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                "SELECT id, post_id, sender_id, recipient_id, subject, body, attachment_url, created_at, read_at FROM {$table} WHERE sender_id = %d OR recipient_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
                $user_id,
                $user_id,
                self::BATCH_SIZE,
                $offset
            ),
            ARRAY_A
        ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ( (array) $rows as $row ) {
            $data[] = [
                'group_id'    => 'nexdirectory-messages',
                'group_label' => __( 'Nex Directory private messages', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-message-' . absint( $row['id'] ?? 0 ),
                'data'        => self::row_to_export_data( $row, [ 'id' ], absint( $row['post_id'] ?? 0 ) ),
            ];
        }
        return (bool) ( $done && count( (array) $rows ) < self::BATCH_SIZE );
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int  $user_id User ID.
     * @param int  $offset Offset.
     * @param bool $done Current state.
     * @return bool
     */
    private static function export_notifications( array &$data, $user_id, $offset, $done ) {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_notifications';
        if ( ! self::table_exists( $table ) ) {
            return (bool) $done;
        }
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; privacy exports are request-specific personal data and must not be cached.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                "SELECT id, actor_id, type, object_id, secondary_id, title, body, url, created_at, read_at FROM {$table} WHERE recipient_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
                $user_id,
                self::BATCH_SIZE,
                $offset
            ),
            ARRAY_A
        ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ( (array) $rows as $row ) {
            $data[] = [
                'group_id'    => 'nexdirectory-notifications',
                'group_label' => __( 'Nex Directory account notifications', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-notification-' . absint( $row['id'] ?? 0 ),
                'data'        => self::row_to_export_data( $row, [ 'id' ], absint( $row['object_id'] ?? 0 ) ),
            ];
        }
        return (bool) ( $done && count( (array) $rows ) < self::BATCH_SIZE );
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int  $user_id User ID.
     * @param int  $offset Offset.
     * @param bool $done Current state.
     * @return bool
     */
    private static function export_analytics( array &$data, $user_id, $offset, $done ) {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_event_analytics';
        if ( ! self::table_exists( $table ) ) {
            return (bool) $done;
        }
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; privacy exports are request-specific personal data and must not be cached.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                "SELECT id, event_id, metric, context, meta_key, meta_value, value, created_at FROM {$table} WHERE user_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
                $user_id,
                self::BATCH_SIZE,
                $offset
            ),
            ARRAY_A
        ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ( (array) $rows as $row ) {
            $data[] = [
                'group_id'    => 'nexdirectory-analytics',
                'group_label' => __( 'Nex Directory analytics activity', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-analytics-' . absint( $row['id'] ?? 0 ),
                'data'        => self::row_to_export_data( $row, [ 'id' ], absint( $row['event_id'] ?? 0 ) ),
            ];
        }
        return (bool) ( $done && count( (array) $rows ) < self::BATCH_SIZE );
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int  $user_id User ID.
     * @param int  $offset Offset.
     * @param bool $done Current state.
     * @return bool
     */
    private static function export_private_files( array &$data, $user_id, $offset, $done ) {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_private_files';
        if ( ! self::table_exists( $table ) ) {
            return (bool) $done;
        }
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; privacy exports are request-specific personal data and must not be cached.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                "SELECT id, event_id, owner_user_id, recipient_user_id, original_name, mime_type, file_size, created_at FROM {$table} WHERE owner_user_id = %d OR recipient_user_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
                $user_id,
                $user_id,
                self::BATCH_SIZE,
                $offset
            ),
            ARRAY_A
        ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ( (array) $rows as $row ) {
            $data[] = [
                'group_id'    => 'nexdirectory-private-files',
                'group_label' => __( 'Nex Directory private attachments', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-private-file-' . absint( $row['id'] ?? 0 ),
                'data'        => self::row_to_export_data( $row, [ 'id' ], absint( $row['event_id'] ?? 0 ) ),
            ];
        }
        return (bool) ( $done && count( (array) $rows ) < self::BATCH_SIZE );
    }

    /**
     * @param array<int, array<string, mixed>> $data Export data.
     * @param int    $user_id User ID.
     * @param string $email_address Request email.
     * @param int    $offset Offset.
     * @param bool   $done Current state.
     * @return bool
     */
    private static function export_runtime_logs( array &$data, $user_id, $email_address, $offset, $done ) {
        $matches = [];
        foreach ( (array) get_option( 'nexdirectory_runtime_logs', [] ) as $index => $entry ) {
            if ( ! is_array( $entry ) || ! self::value_references_user( $entry, $user_id, $email_address ) ) {
                continue;
            }
            $matches[] = [ 'index' => $index, 'entry' => $entry ];
        }
        foreach ( array_slice( $matches, $offset, self::BATCH_SIZE ) as $match ) {
            $entry = (array) $match['entry'];
            $data[] = [
                'group_id'    => 'nexdirectory-runtime-logs',
                'group_label' => __( 'Nex Directory diagnostic activity', 'nexdirectory' ),
                'item_id'     => 'nexdirectory-runtime-log-' . absint( $match['index'] ),
                'data'        => [
                    [ 'name' => __( 'Time', 'nexdirectory' ), 'value' => sanitize_text_field( (string) ( $entry['timestamp'] ?? $entry['time'] ?? '' ) ) ],
                    [ 'name' => __( 'Level', 'nexdirectory' ), 'value' => sanitize_key( (string) ( $entry['level'] ?? '' ) ) ],
                    [ 'name' => __( 'Message', 'nexdirectory' ), 'value' => self::redact_other_emails( sanitize_textarea_field( (string) ( $entry['message'] ?? '' ) ), $email_address ) ],
                    [ 'name' => __( 'Context', 'nexdirectory' ), 'value' => self::redact_other_emails( sanitize_textarea_field( (string) wp_json_encode( $entry['context'] ?? [] ) ), $email_address ) ],
                ],
            ];
        }
        return (bool) ( $done && count( $matches ) <= $offset + self::BATCH_SIZE );
    }

    /**
     * @param int  $user_id User ID.
     * @param bool $items_removed Removal flag.
     * @return void
     */
    private static function erase_private_files( $user_id, &$items_removed ) {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_private_files';
        if ( ! self::table_exists( $table ) ) {
            return;
        }
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The table is prefix-derived and plugin-owned; the erasure scan is request-specific personal data and must not be cached.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                "SELECT id, storage_path FROM {$table} WHERE owner_user_id = %d",
                $user_id
            ),
            ARRAY_A
        ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        foreach ( (array) $rows as $row ) {
            self::delete_private_file_path( (string) ( $row['storage_path'] ?? '' ) );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy erasure writes directly to the plugin-owned private-file table and cannot use a cached result.
            $deleted = $wpdb->delete( $table, [ 'id' => absint( $row['id'] ?? 0 ) ], [ '%d' ] );
            $items_removed = $items_removed || ( is_int( $deleted ) && $deleted > 0 );
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy anonymization writes directly to the plugin-owned private-file table and cannot use a cached result.
        $unlinked = $wpdb->update(
            $table,
            [ 'recipient_user_id' => 0 ],
            [ 'recipient_user_id' => $user_id ],
            [ '%d' ],
            [ '%d' ]
        );
        $items_removed = $items_removed || ( is_int( $unlinked ) && $unlinked > 0 );
    }

    /**
     * Deletes a private attachment only when its canonical path is contained
     * by the current site's canonical private-storage directory.
     *
     * @param string $path Stored path.
     * @return void
     */
    private static function delete_private_file_path( $path ) {
        $path              = (string) $path;
        $storage_directory = self::get_private_storage_directory_path();
        if ( $path === '' || $storage_directory === '' ) {
            return;
        }

        $real_path      = realpath( $path );
        $real_directory = realpath( $storage_directory );
        if ( false === $real_path || false === $real_directory ) {
            return;
        }

        $normalized_path      = wp_normalize_path( $real_path );
        $normalized_directory = trailingslashit( wp_normalize_path( $real_directory ) );
        if ( strpos( $normalized_path, $normalized_directory ) !== 0 || ! is_file( $real_path ) ) {
            return;
        }

        wp_delete_file( $real_path );
    }

    /**
     * Returns the configured per-site private-storage path without creating it.
     *
     * This intentionally mirrors the path selection used by the private-file
     * service. Canonical containment is checked separately before deletion.
     *
     * @return string
     */
    private static function get_private_storage_directory_path() {
        $default_base = function_exists( 'nexdirectory_get_upload_subdirectory' )
            ? nexdirectory_get_upload_subdirectory( 'private' )
            : '';
        if ( is_wp_error( $default_base ) || (string) $default_base === '' ) {
            return '';
        }
        $configured_base     = (string) apply_filters(
            'nexdirectory_private_storage_base',
            $default_base,
            get_current_blog_id()
        );
        $configured_base     = untrailingslashit( wp_normalize_path( $configured_base ) );

        $base_directory = $configured_base !== '' ? $configured_base : untrailingslashit( wp_normalize_path( $default_base ) );

        $is_absolute_base = strpos( $base_directory, '/' ) === 0
            || (bool) preg_match( '/^[A-Za-z]:[\\\\\/]/', $base_directory );
        if ( ! $is_absolute_base ) {
            return '';
        }

        return trailingslashit( $base_directory ) . 'site-' . get_current_blog_id();
    }

    /**
     * @param int $user_id User ID.
     * @return bool
     */
    private static function erase_messages( $user_id ) {
        global $wpdb;
        $messages_table = $wpdb->prefix . 'nexdirectory_inbox_messages';
        $threads_table  = $wpdb->prefix . 'nexdirectory_inbox_threads';
        $changed        = false;

        if ( self::table_exists( $messages_table ) ) {
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The prefix-derived custom message table must be updated directly for privacy erasure; write results are not cacheable.
            $sent = $wpdb->query(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                    "UPDATE {$messages_table} SET sender_id = 0, subject = '', body = '', attachment_url = '' WHERE sender_id = %d",
                    $user_id
                )
            ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The prefix-derived custom message table must be updated directly for privacy erasure; write results are not cacheable.
            $received = $wpdb->query(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is plugin-owned and built from the trusted WordPress prefix.
                    "UPDATE {$messages_table} SET recipient_id = 0 WHERE recipient_id = %d",
                    $user_id
                )
            ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $changed = ( is_int( $sent ) && $sent > 0 ) || ( is_int( $received ) && $received > 0 );
        }
        if ( self::table_exists( $threads_table ) ) {
            foreach ( [ 'participant_a', 'participant_b', 'last_sender_id' ] as $column ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Privacy anonymization writes directly to the plugin-owned thread table and cannot use a cached result.
                $updated = $wpdb->update(
                    $threads_table,
                    [ $column => 0 ],
                    [ $column => $user_id ],
                    [ '%d' ],
                    [ '%d' ]
                );
                $changed = $changed || ( is_int( $updated ) && $updated > 0 );
            }
        }
        return $changed;
    }

    /**
     * @param int    $user_id User ID.
     * @param string $email_address Request email.
     * @param bool   $items_removed Removal flag.
     * @param bool   $items_retained Retention flag.
     * @param array<int, string> $messages Result messages.
     * @return void
     */
    private static function erase_runtime_logs( $user_id, $email_address, &$items_removed, &$items_retained, array &$messages ) {
        $logs    = get_option( 'nexdirectory_runtime_logs', [] );
        $changed = false;
        $matched = false;
        if ( ! is_array( $logs ) ) {
            return;
        }
        foreach ( $logs as $index => $entry ) {
            if ( ! is_array( $entry ) || ! self::value_references_user( $entry, $user_id, $email_address ) ) {
                continue;
            }
            $matched = true;
            $logs[ $index ] = self::anonymize_log_value( $entry, '', $user_id, $email_address, $changed, true );
        }
        if ( ! $matched ) {
            return;
        }
        $items_retained = true;
        if ( $changed && update_option( 'nexdirectory_runtime_logs', $logs, false ) ) {
            $items_removed = true;
            $messages[] = __( 'Matching identifiers in diagnostic logs were anonymized while non-identifying operational entries were retained.', 'nexdirectory' );
        }
    }

    /**
     * @param mixed  $value Value.
     * @param int    $user_id User ID.
     * @param string $email_address Email.
     * @return bool
     */
    private static function value_references_user( $value, $user_id, $email_address ) {
        if ( is_object( $value ) ) {
            $value = (array) $value;
        }
        if ( is_array( $value ) ) {
            foreach ( $value as $key => $child ) {
                if ( self::value_references_user_keyed( $child, (string) $key, $user_id, $email_address ) ) {
                    return true;
                }
            }
            return false;
        }
        return $email_address !== '' && is_scalar( $value ) && stripos( (string) $value, $email_address ) !== false;
    }

    /**
     * @param mixed  $value Value.
     * @param string $key Key.
     * @param int    $user_id User ID.
     * @param string $email_address Email.
     * @return bool
     */
    private static function value_references_user_keyed( $value, $key, $user_id, $email_address ) {
        if ( is_array( $value ) || is_object( $value ) ) {
            return self::value_references_user( $value, $user_id, $email_address );
        }
        $scalar = is_scalar( $value ) ? trim( (string) $value ) : '';
        $key    = sanitize_key( $key );
        if ( $user_id > 0 && preg_match( '/(?:^|_)user_id$|^(?:sender|recipient|actor|author|moderator)_id$/', $key ) && absint( $scalar ) === $user_id ) {
            return true;
        }
        return $email_address !== '' && $scalar !== '' && stripos( $scalar, $email_address ) !== false;
    }

    /**
     * @param mixed  $value Value.
     * @param string $key Key.
     * @param int    $user_id User ID.
     * @param string $email_address Email.
     * @param bool   $changed Changed flag.
     * @param bool   $matched_entry Matched entry flag.
     * @return mixed
     */
    private static function anonymize_log_value( $value, $key, $user_id, $email_address, &$changed, $matched_entry ) {
        if ( is_object( $value ) ) {
            $value = (array) $value;
        }
        if ( is_array( $value ) ) {
            foreach ( $value as $child_key => $child ) {
                $value[ $child_key ] = self::anonymize_log_value( $child, (string) $child_key, $user_id, $email_address, $changed, $matched_entry );
            }
            return $value;
        }
        if ( ! is_scalar( $value ) && null !== $value ) {
            return $value;
        }
        $scalar = (string) $value;
        $key    = sanitize_key( $key );
        if ( $user_id > 0 && preg_match( '/(?:^|_)user_id$|^(?:sender|recipient|actor|author|moderator)_id$/', $key ) && absint( $scalar ) === $user_id ) {
            $changed = true;
            return 0;
        }
        if ( $matched_entry && 'ip' === $key && $scalar !== '' ) {
            $changed = true;
            return '';
        }
        if ( $email_address !== '' && stripos( $scalar, $email_address ) !== false ) {
            $changed = true;
            return str_ireplace( $email_address, '[redacted email]', $scalar );
        }
        return $value;
    }

    /**
     * @param string $value Text.
     * @param string $email_address Request email.
     * @return string
     */
    private static function redact_other_emails( $value, $email_address ) {
        $request = strtolower( sanitize_email( $email_address ) );
        $result = preg_replace_callback(
            '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i',
            static function ( $matches ) use ( $request ) {
                $email = sanitize_email( (string) ( $matches[0] ?? '' ) );
                return $request !== '' && strtolower( $email ) === $request ? $email : '[redacted email]';
            },
            (string) $value
        );
        return is_string( $result ) ? $result : (string) $value;
    }

    /**
     * @param array<string, mixed> $row Row.
     * @param array<int, string>   $skip_keys Keys to omit.
     * @param int                  $event_id Listing ID.
     * @return array<int, array<string, string>>
     */
    private static function row_to_export_data( array $row, array $skip_keys, $event_id ) {
        $data = [];
        if ( $event_id > 0 ) {
            $data[] = [ 'name' => __( 'Listing', 'nexdirectory' ), 'value' => self::event_label( $event_id ) ];
        }
        foreach ( $row as $key => $value ) {
            if ( in_array( (string) $key, $skip_keys, true ) ) {
                continue;
            }
            $data[] = [
                'name'  => sanitize_text_field( ucwords( str_replace( '_', ' ', (string) $key ) ) ),
                'value' => sanitize_textarea_field( is_scalar( $value ) ? (string) $value : (string) wp_json_encode( $value ) ),
            ];
        }
        return $data;
    }

    /**
     * @param int $event_id Listing ID.
     * @return string
     */
    private static function event_label( $event_id ) {
        $event_id = absint( $event_id );
        if ( $event_id <= 0 ) {
            return '';
        }
        $title = sanitize_text_field( (string) get_the_title( $event_id ) );
        return $title !== '' ? $title . ' (#' . $event_id . ')' : '#' . $event_id;
    }

    /**
     * @param string $table Table name.
     * @return bool
     */
    private static function table_exists( $table ) {
        global $wpdb;
        $table = (string) $table;
        if ( $table === '' ) {
            return false;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prefix-derived custom-table schema introspection has no WordPress API equivalent and should not persist stale migration state.
        return (string) $wpdb->get_var(
            $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) )
        ) === $table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
    }
}
