<?php
/**
 * Organizer profiles, privacy-aware calendar feeds, and private attachments.
 *
 * @package NexDirectory
 */

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

trait NEXDIRECTORY_Profile_Calendar_Trait {
    /**
     * Registers hooks owned by this feature module.
     *
     * @return void
     */
    public function register_profile_calendar_hooks() {
        add_filter('query_vars', [$this, 'add_profile_calendar_query_vars']);
        add_action('template_redirect', [$this, 'handle_profile_calendar_requests'], 0);
    }

    /**
     * Adds public request variables without requiring rewrite rules.
     *
     * @param array<int, string> $vars Existing public query variables.
     * @return array<int, string>
     */
    public function add_profile_calendar_query_vars($vars) {
        $vars = is_array($vars) ? $vars : [];
        $vars[] = 'nexdirectory_calendar_feed';
        $vars[] = 'nexdirectory_calendar_scope';
        $vars[] = 'nexdirectory_calendar_user';
        $vars[] = 'nexdirectory_calendar_token';
        $vars[] = 'nexdirectory_private_file';
        $vars[] = 'nexdirectory_private_token';
        return array_values(array_unique($vars));
    }

    /**
     * Handles private downloads and iCalendar subscriptions before templates run.
     *
     * @return void
     */
    public function handle_profile_calendar_requests() {
        $private_file_id = absint((int) get_query_var('nexdirectory_private_file'));
        if ($private_file_id > 0) {
            $this->serve_private_attachment($private_file_id);
        }

        $feed_flag = sanitize_key((string) get_query_var('nexdirectory_calendar_feed'));
        if (in_array($feed_flag, ['1', 'yes', 'true', 'on'], true)) {
            $this->serve_calendar_feed();
        }
    }

    /**
     * Returns a privacy-safe organizer profile data transfer object.
     *
     * Core account email, login, biography, and Gravatar are never exposed by
     * default. Public contact fields require an explicit account-level opt-in.
     *
     * @param int  $user_id Organizer user ID.
     * @param bool $allow_unlisted Whether a direct unlisted profile may be returned.
     * @return array<string, mixed>
     */
    public function get_public_organizer_profile($user_id, $allow_unlisted = false) {
        $user_id = absint((int) $user_id);
        $user = $user_id > 0 ? get_user_by('id', $user_id) : false;
        if (!$user instanceof WP_User) {
            return [];
        }

        $visibility = sanitize_key((string) get_user_meta($user_id, 'nexdirectory_profile_visibility', true));
        if (!in_array($visibility, ['public', 'unlisted', 'private'], true)) {
            // Existing accounts have not opted into the people directory.
            $visibility = 'unlisted';
        }
        $viewer_can_manage = is_user_logged_in()
            && ((int) get_current_user_id() === $user_id || current_user_can('manage_options'));
        if ($visibility === 'private' && !$viewer_can_manage) {
            return [];
        }
        if ($visibility === 'unlisted' && !$allow_unlisted && !$viewer_can_manage) {
            return [];
        }

        $public_name = sanitize_text_field((string) get_user_meta($user_id, 'nexdirectory_profile_public_name', true));
        if ($public_name === '') {
            $candidate_name = sanitize_text_field((string) $user->display_name);
            $login_name = sanitize_text_field((string) $user->user_login);
            $public_name = ($candidate_name !== '' && !hash_equals($candidate_name, $login_name))
                ? $candidate_name
                : __('Organizer', 'nexdirectory');
        }

        $show_email = (string) get_user_meta($user_id, 'nexdirectory_profile_show_email', true) === '1';
        $show_phone = (string) get_user_meta($user_id, 'nexdirectory_profile_show_phone', true) === '1';
        $show_member_since = (string) get_user_meta($user_id, 'nexdirectory_profile_show_member_since', true) === '1';
        $public_email = sanitize_email((string) get_user_meta($user_id, 'nexdirectory_profile_public_email', true));
        if ($show_email && !is_email($public_email)) {
            $public_email = sanitize_email((string) $user->user_email);
        }
        if (!$show_email || !is_email($public_email)) {
            $public_email = '';
        }
        $public_phone = $show_phone
            ? sanitize_text_field((string) get_user_meta($user_id, 'nexdirectory_profile_public_phone', true))
            : '';

        $member_since = '';
        if ($show_member_since && !empty($user->user_registered)) {
            $registered_timestamp = strtotime((string) $user->user_registered);
            if ($registered_timestamp) {
                $member_since = wp_date(get_option('date_format'), (int) $registered_timestamp);
            }
        }

        $calendar_visibility = sanitize_key((string) get_user_meta($user_id, 'nexdirectory_calendar_visibility', true));
        if (!in_array($calendar_visibility, ['public', 'private'], true)) {
            $calendar_visibility = 'private';
        }

        $profiles_url = $this->get_platform_page_url(
            'nexdirectory_page_organizers_url',
            $this->get_profile_route_slugs('organizers'),
            $this->get_profile_route_default_url('organizers')
        );
        $profile_url = $profiles_url !== ''
            ? nexdirectory_add_query_arg('organizer_profile', $user_id, $profiles_url)
            : '';
        $events_url = $this->get_platform_page_url(
            'nexdirectory_page_events_url',
            $this->get_profile_route_slugs('events'),
            $this->get_profile_route_default_url('events')
        );
        $events_url = $events_url !== ''
            ? nexdirectory_add_query_arg('organizer', $user_id, $events_url)
            : '';

        return [
            'user_id' => $user_id,
            'visibility' => $visibility,
            'name' => $public_name,
            'tagline' => sanitize_text_field((string) get_user_meta($user_id, 'nexdirectory_profile_tagline', true)),
            'bio' => sanitize_textarea_field((string) get_user_meta($user_id, 'nexdirectory_profile_bio', true)),
            'location' => sanitize_text_field((string) get_user_meta($user_id, 'nexdirectory_profile_location', true)),
            'avatar_url' => esc_url_raw((string) get_user_meta($user_id, 'nexdirectory_profile_avatar_url', true)),
            'website_url' => esc_url_raw((string) get_user_meta($user_id, 'nexdirectory_profile_website_url', true)),
            'linkedin_url' => esc_url_raw((string) get_user_meta($user_id, 'nexdirectory_profile_linkedin_url', true)),
            'instagram_url' => esc_url_raw((string) get_user_meta($user_id, 'nexdirectory_profile_instagram_url', true)),
            'email' => $public_email,
            'phone' => $public_phone,
            'member_since' => $member_since,
            'verified' => (string) get_user_meta($user_id, 'nexdirectory_verified_organizer', true) === '1',
            'listing_count' => (int) count_user_posts($user_id, self::POST_TYPE, true),
            'profile_url' => esc_url_raw((string) $profile_url),
            'events_url' => esc_url_raw((string) $events_url),
            'calendar_url' => $calendar_visibility === 'public' && $visibility !== 'private'
                ? $this->get_organizer_calendar_feed_url($user_id)
                : '',
            'calendar_webcal_url' => $calendar_visibility === 'public' && $visibility !== 'private'
                ? $this->calendar_url_to_webcal($this->get_organizer_calendar_feed_url($user_id))
                : '',
        ];
    }

    /**
     * Returns organizer profiles appropriate for the public directory.
     *
     * @param int  $limit Maximum number of user accounts to inspect.
     * @param bool $verified_only Limit results to verified organizers.
     * @return array<int, array<string, mixed>>
     */
    public function get_public_organizer_profiles($limit = 200, $verified_only = false) {
        $limit = max(1, min(500, absint((int) $limit)));
        $users = get_users([
            'orderby' => 'display_name',
            'order' => 'ASC',
            'number' => $limit,
            'fields' => ['ID'],
        ]);
        $profiles = [];
        foreach ((array) $users as $user) {
            $user_id = $user instanceof WP_User ? (int) $user->ID : absint((int) ($user->ID ?? 0));
            if ($user_id <= 0 || count_user_posts($user_id, self::POST_TYPE, true) < 1) {
                continue;
            }
            $profile = $this->get_public_organizer_profile($user_id, false);
            if (empty($profile) || ($verified_only && empty($profile['verified']))) {
                continue;
            }
            $profiles[] = $profile;
        }
        return $profiles;
    }

    /**
     * Saves explicit organizer and attendee privacy preferences.
     *
     * @param int                 $user_id User ID.
     * @param array<string,mixed> $source Untrusted request values.
     * @return void
     */
    public function save_profile_privacy_settings($user_id, $source) {
        $user_id = absint((int) $user_id);
        $source = is_array($source) ? $source : [];
        if ($user_id <= 0) {
            return;
        }

        $visibility = sanitize_key((string) wp_unslash($source['nexdirectory_profile_visibility'] ?? 'unlisted'));
        if (!in_array($visibility, ['public', 'unlisted', 'private'], true)) {
            $visibility = 'unlisted';
        }
        $calendar_visibility = sanitize_key((string) wp_unslash($source['nexdirectory_calendar_visibility'] ?? 'private'));
        if (!in_array($calendar_visibility, ['public', 'private'], true)) {
            $calendar_visibility = 'private';
        }
        $attendee_visibility = sanitize_key((string) wp_unslash($source['nexdirectory_attendee_visibility'] ?? 'members'));
        if (!in_array($attendee_visibility, ['public', 'members', 'private'], true)) {
            $attendee_visibility = 'members';
        }

        update_user_meta($user_id, 'nexdirectory_profile_visibility', $visibility);
        update_user_meta($user_id, 'nexdirectory_calendar_visibility', $calendar_visibility);
        update_user_meta($user_id, 'nexdirectory_attendee_visibility', $attendee_visibility);

        $text_fields = [
            'nexdirectory_profile_public_name',
            'nexdirectory_profile_tagline',
            'nexdirectory_profile_location',
            'nexdirectory_profile_public_phone',
        ];
        foreach ($text_fields as $meta_key) {
            update_user_meta($user_id, $meta_key, sanitize_text_field((string) wp_unslash($source[$meta_key] ?? '')));
        }
        update_user_meta(
            $user_id,
            'nexdirectory_profile_bio',
            sanitize_textarea_field((string) wp_unslash($source['nexdirectory_profile_bio'] ?? ''))
        );

        $url_fields = [
            'nexdirectory_profile_avatar_url',
            'nexdirectory_profile_website_url',
            'nexdirectory_profile_linkedin_url',
            'nexdirectory_profile_instagram_url',
        ];
        foreach ($url_fields as $meta_key) {
            update_user_meta($user_id, $meta_key, esc_url_raw((string) wp_unslash($source[$meta_key] ?? '')));
        }

        $public_email = sanitize_email((string) wp_unslash($source['nexdirectory_profile_public_email'] ?? ''));
        update_user_meta($user_id, 'nexdirectory_profile_public_email', is_email($public_email) ? $public_email : '');

        foreach (['nexdirectory_profile_show_email', 'nexdirectory_profile_show_phone', 'nexdirectory_profile_show_member_since'] as $boolean_key) {
            update_user_meta($user_id, $boolean_key, (string) wp_unslash($source[$boolean_key] ?? '0') === '1' ? '1' : '0');
        }
    }

    /**
     * Site-wide public calendar subscription URL.
     *
     * @return string
     */
    public function get_site_calendar_feed_url() {
        return esc_url_raw((string) nexdirectory_add_query_arg([
            'nexdirectory_calendar_feed' => '1',
            'nexdirectory_calendar_scope' => 'site',
        ], home_url('/')));
    }

    /**
     * Organizer public calendar subscription URL.
     *
     * @param int $user_id Organizer ID.
     * @return string
     */
    public function get_organizer_calendar_feed_url($user_id) {
        return esc_url_raw((string) nexdirectory_add_query_arg([
            'nexdirectory_calendar_feed' => '1',
            'nexdirectory_calendar_scope' => 'organizer',
            'nexdirectory_calendar_user' => absint((int) $user_id),
        ], home_url('/')));
    }

    /**
     * Personal attending calendar subscription URL.
     *
     * @param int $user_id User ID.
     * @return string
     */
    public function get_attending_calendar_feed_url($user_id) {
        $url = function_exists('nexdirectory_call_extension_method')
            ? nexdirectory_call_extension_method(__FUNCTION__, [absint((int) $user_id)], '')
            : '';
        return esc_url_raw(is_string($url) ? $url : '');
    }

    /**
     * Converts an HTTP subscription URL to the webcal scheme.
     *
     * @param string $url Calendar URL.
     * @return string
     */
    public function calendar_url_to_webcal($url) {
        $url = esc_url_raw((string) $url);
        if (strpos($url, 'https://') === 0) {
            return 'webcal://' . substr($url, 8);
        }
        if (strpos($url, 'http://') === 0) {
            return 'webcal://' . substr($url, 7);
        }
        return '';
    }

    /**
     * Outputs a privacy-aware RFC 5545 subscription.
     *
     * @return void
     */
    private function serve_calendar_feed() {
        $scope = sanitize_key((string) get_query_var('nexdirectory_calendar_scope'));
        $scope = $scope !== '' ? $scope : 'site';
        $user_id = absint((int) get_query_var('nexdirectory_calendar_user'));

        $query_args = [
            'post_type' => self::POST_TYPE,
            'post_status' => 'publish',
            'posts_per_page' => 500,
            'fields' => 'ids',
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Calendar chronology is stored in the plugin's start-date field.
            'meta_key' => 'nexdirectory_start',
            'orderby' => 'meta_value',
            'order' => 'ASC',
            'no_found_rows' => true,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The feed must exclude stale listings by their canonical start date.
            'meta_query' => [
                [
                    'key' => 'nexdirectory_start',
                    'value' => wp_date(
                        'Y-m-d H:i:s',
                        time() - (30 * DAY_IN_SECONDS),
                        wp_timezone()
                    ),
                    'compare' => '>=',
                    'type' => 'DATETIME',
                ],
            ],
        ];
        $feed_name = sanitize_text_field((string) get_bloginfo('name')) . ' — ' . __('Upcoming listings', 'nexdirectory');
        $private_feed = false;

        if ($scope === 'organizer') {
            $profile_visibility = sanitize_key((string) get_user_meta($user_id, 'nexdirectory_profile_visibility', true));
            if (!in_array($profile_visibility, ['public', 'unlisted', 'private'], true)) {
                $profile_visibility = 'unlisted';
            }
            // Organizer subscription URLs are public bearer-less URLs. Never let
            // the current browser session turn a private profile into a publicly
            // cacheable response for that same URL.
            if ($profile_visibility === 'private') {
                status_header(404);
                exit;
            }
            $profile = $this->get_public_organizer_profile($user_id, true);
            $calendar_visibility = sanitize_key((string) get_user_meta($user_id, 'nexdirectory_calendar_visibility', true));
            if (empty($profile) || $calendar_visibility !== 'public') {
                status_header(404);
                exit;
            }
            $query_args['author'] = $user_id;
            $feed_name = sprintf(
                /* translators: %s: Organizer public name. */
                __('Upcoming listings by %s', 'nexdirectory'),
                (string) ($profile['name'] ?? __('Organizer', 'nexdirectory'))
            );
        } elseif ($scope !== 'site') {
            $extension_scope = apply_filters(
                'nexdirectory_calendar_feed_scope',
                ['handled' => false],
                $scope,
                $user_id,
                $query_args
            );
            if (
                !is_array($extension_scope)
                || empty($extension_scope['handled'])
                || empty($extension_scope['valid'])
            ) {
                status_header(404);
                exit;
            }
            if (isset($extension_scope['query_args']) && is_array($extension_scope['query_args'])) {
                $query_args = array_replace($query_args, $extension_scope['query_args']);
            }
            $feed_name = sanitize_text_field((string) ($extension_scope['feed_name'] ?? __('Subscribed calendar', 'nexdirectory')));
            $private_feed = !empty($extension_scope['private']);
        }

        $query = new WP_Query($query_args);
        $post_ids = array_values(array_filter(array_map('absint', (array) $query->posts)));
        $calendar_lines = [
            'BEGIN:VCALENDAR',
            'VERSION:2.0',
            'PRODID:-//Nex Directory//Subscriptions//EN',
            'CALSCALE:GREGORIAN',
            'METHOD:PUBLISH',
            'X-WR-CALNAME:' . $this->escape_ics_text($feed_name),
            'REFRESH-INTERVAL;VALUE=DURATION:PT15M',
            'X-PUBLISHED-TTL:PT15M',
        ];
        $latest_modified = 0;
        foreach ($post_ids as $post_id) {
            $event_lines = $this->build_calendar_event_lines($post_id);
            if (empty($event_lines)) {
                continue;
            }
            $calendar_lines = array_merge($calendar_lines, $event_lines);
            $modified_timestamp = strtotime((string) get_post_field('post_modified_gmt', $post_id) . ' UTC');
            $latest_modified = max($latest_modified, $modified_timestamp ? (int) $modified_timestamp : 0);
        }
        $calendar_lines[] = 'END:VCALENDAR';
        $ics = implode("\r\n", array_map([$this, 'fold_ics_line'], $calendar_lines)) . "\r\n";
        if ($latest_modified <= 0) {
            $site_modified = strtotime((string) get_lastpostmodified('GMT') . ' UTC');
            $latest_modified = $site_modified ? (int) $site_modified : 946684800;
        }
        $etag = '"' . md5($scope . '|' . $user_id . '|' . $ics) . '"';

        $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH'])
            ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_IF_NONE_MATCH']))
            : '';
        $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])
            ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_IF_MODIFIED_SINCE']))
            : '';
        $etag_matches = $if_none_match !== '' && hash_equals($etag, $if_none_match);
        $modified_matches = $if_none_match === ''
            && !$private_feed
            && $if_modified_since !== ''
            && strtotime($if_modified_since) >= $latest_modified;
        if ($etag_matches || $modified_matches) {
            status_header(304);
            exit;
        }

        status_header(200);
        header('Content-Type: text/calendar; charset=utf-8');
        header('Content-Disposition: inline; filename="' . sanitize_file_name($scope . '-calendar.ics') . '"');
        header('ETag: ' . $etag);
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $latest_modified) . ' GMT');
        header('X-Content-Type-Options: nosniff');
        if ($private_feed) {
            header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
            header('X-Robots-Tag: noindex, nofollow, noarchive');
        } else {
            header('Cache-Control: public, max-age=300, stale-while-revalidate=600');
        }
        echo $ics; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- RFC 5545 bytes are escaped and must not be HTML encoded.
        exit;
    }

        /**
     * Builds a VEVENT with stable identity and update metadata.
     *
     * @param int $post_id Listing ID.
     * @return array<int, string>
     */
    private function build_calendar_event_lines($post_id) {
        $post_id = absint((int) $post_id);
        $post = $post_id > 0 ? get_post($post_id) : null;
        if (!$post instanceof WP_Post || $post->post_status !== 'publish') {
            return [];
        }

        $start_raw = trim((string) get_post_meta($post_id, 'nexdirectory_start', true));
        $end_raw = trim((string) get_post_meta($post_id, 'nexdirectory_end', true));
        $timezone_name = $this->sanitize_timezone_value((string) get_post_meta($post_id, 'nexdirectory_timezone', true));
        try {
            $event_timezone = $timezone_name !== '' ? new DateTimeZone($timezone_name) : wp_timezone();
        } catch (Exception $exception) {
            $event_timezone = wp_timezone();
        }
        $is_all_day = (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $start_raw);
        $start_datetime = $this->parse_ics_event_datetime($start_raw, $event_timezone, $is_all_day);
        if (!$start_datetime instanceof DateTimeImmutable) {
            return [];
        }
        $end_datetime = $this->parse_ics_event_datetime($end_raw, $event_timezone, $is_all_day);
        if (!$end_datetime instanceof DateTimeImmutable) {
            $end_datetime = $is_all_day ? $start_datetime->modify('+1 day') : $start_datetime->modify('+1 hour');
        } elseif ($is_all_day) {
            $end_datetime = $end_datetime->modify('+1 day');
        }
        if ($end_datetime <= $start_datetime) {
            $end_datetime = $is_all_day ? $start_datetime->modify('+1 day') : $start_datetime->modify('+1 hour');
        }

        $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
        $host = trim((string) preg_replace('/[^a-z0-9.-]/', '', $host), '.-');
        $host = $host !== '' ? $host : 'local';
        $modified_timestamp = strtotime((string) $post->post_modified_gmt . ' UTC');
        $published_timestamp = strtotime((string) $post->post_date_gmt . ' UTC');
        $modified_timestamp = $modified_timestamp ? (int) $modified_timestamp : time();
        $published_timestamp = $published_timestamp ? (int) $published_timestamp : $modified_timestamp;
        $sequence = max(0, (int) floor(($modified_timestamp - $published_timestamp) / MINUTE_IN_SECONDS));
        $summary = html_entity_decode(nexdirectory_strip_all_tags((string) $post->post_title), ENT_QUOTES, get_bloginfo('charset') ?: 'UTF-8');
        $description = html_entity_decode(
            nexdirectory_strip_all_tags((string) ($post->post_excerpt ?: $post->post_content)),
            ENT_QUOTES,
            get_bloginfo('charset') ?: 'UTF-8'
        );
        $location = html_entity_decode(
            nexdirectory_strip_all_tags((string) get_post_meta($post_id, 'nexdirectory_address', true)),
            ENT_QUOTES,
            get_bloginfo('charset') ?: 'UTF-8'
        );

        $lines = [
            'BEGIN:VEVENT',
            'UID:nexdirectory-' . get_current_blog_id() . '-' . $post_id . '@' . $host,
            'DTSTAMP:' . gmdate('Ymd\THis\Z', $modified_timestamp),
            'CREATED:' . gmdate('Ymd\THis\Z', $published_timestamp),
            'LAST-MODIFIED:' . gmdate('Ymd\THis\Z', $modified_timestamp),
            'SEQUENCE:' . $sequence,
            'STATUS:CONFIRMED',
        ];
        if ($is_all_day) {
            $lines[] = 'DTSTART;VALUE=DATE:' . $start_datetime->format('Ymd');
            $lines[] = 'DTEND;VALUE=DATE:' . $end_datetime->format('Ymd');
        } else {
            $utc_timezone = new DateTimeZone('UTC');
            $lines[] = 'DTSTART:' . $start_datetime->setTimezone($utc_timezone)->format('Ymd\THis\Z');
            $lines[] = 'DTEND:' . $end_datetime->setTimezone($utc_timezone)->format('Ymd\THis\Z');
        }
        $lines[] = 'SUMMARY:' . $this->escape_ics_text($summary);
        $lines[] = 'DESCRIPTION:' . $this->escape_ics_text($description);
        if ($location !== '') {
            $lines[] = 'LOCATION:' . $this->escape_ics_text($location);
        }
        $lines[] = 'URL:' . esc_url((string) get_permalink($post_id));
        $lines[] = 'END:VEVENT';
        return $lines;
    }

    /**
     * Creates the private attachment table.
     *
     * @return void
     */
    public function create_private_files_table() {
        global $wpdb;
        $table = $wpdb->prefix . 'nexdirectory_private_files';
        $charset_collate = $wpdb->get_charset_collate();
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        $sql = "CREATE TABLE {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            token_hash char(64) NOT NULL,
            storage_path text NOT NULL,
            original_name varchar(255) NOT NULL DEFAULT '',
            mime_type varchar(100) NOT NULL DEFAULT 'application/pdf',
            file_size bigint(20) unsigned NOT NULL DEFAULT 0,
            event_id bigint(20) unsigned NOT NULL DEFAULT 0,
            message_id bigint(20) unsigned NOT NULL DEFAULT 0,
            owner_user_id bigint(20) unsigned NOT NULL DEFAULT 0,
            recipient_user_id bigint(20) unsigned NOT NULL DEFAULT 0,
            created_at datetime NOT NULL,
            expires_at datetime DEFAULT NULL,
            downloaded_at datetime DEFAULT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY token_hash (token_hash),
            KEY owner_user_id (owner_user_id),
            KEY recipient_user_id (recipient_user_id),
            KEY message_id (message_id),
            KEY expires_at (expires_at)
        ) {$charset_collate};";
        dbDelta($sql);
    }

    /**
     * Moves an uploaded PDF outside the public document root and records it.
     *
     * @param string $source_path Current upload path.
     * @param string $original_name Original browser filename.
     * @param int    $event_id Listing ID.
     * @param int    $owner_user_id Uploading user ID.
     * @param int    $recipient_user_id Recipient user ID.
     * @return array<string, mixed>|WP_Error
     */
    public function protect_uploaded_attachment($source_path, $original_name, $event_id, $owner_user_id, $recipient_user_id) {
        global $wpdb;
        $source_path = (string) $source_path;
        if ($source_path === '' || !is_file($source_path)) {
            return new WP_Error('nexdirectory_private_file_missing', __('The uploaded CV could not be secured.', 'nexdirectory'));
        }
        $storage_directory = $this->get_private_storage_directory();
        if (is_wp_error($storage_directory)) {
            return $storage_directory;
        }

        $token = wp_generate_password(64, false, false);
        $token_hash = hash('sha256', $token);
        $file_key = str_replace('-', '', wp_generate_uuid4());
        $destination = trailingslashit((string) $storage_directory) . $file_key . '.pdf';

        $filesystem = function_exists('nexdirectory_get_wp_filesystem') ? nexdirectory_get_wp_filesystem() : null;
        if (!is_object($filesystem) || !method_exists($filesystem, 'move')) {
            return new WP_Error('nexdirectory_private_file_move', __('The uploaded CV could not be moved to private storage.', 'nexdirectory'));
        }

        $moved = (bool) $filesystem->move($source_path, $destination, true);
        if (!$moved && method_exists($filesystem, 'copy') && $filesystem->copy($source_path, $destination, true, defined('FS_CHMOD_FILE') ? FS_CHMOD_FILE : 0600)) {
            wp_delete_file($source_path);
            $moved = true;
        }
        if (!$moved) {
            return new WP_Error('nexdirectory_private_file_move', __('The uploaded CV could not be moved to private storage.', 'nexdirectory'));
        }
        if (!is_file($destination)) {
            return new WP_Error('nexdirectory_private_file_move', __('The uploaded CV could not be moved to private storage.', 'nexdirectory'));
        }
        if (method_exists($filesystem, 'chmod')) {
            $filesystem->chmod($destination, 0600);
        }

        $retention_days = max(0, min(3650, absint((int) get_option('nexdirectory_privacy_retention_days', 730))));
        $created_at = current_time('mysql', true);
        $expires_at = $retention_days > 0
            ? gmdate('Y-m-d H:i:s', time() + ($retention_days * DAY_IN_SECONDS))
            : null;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- This is a write to a plugin-owned private-file record.
        $inserted = $wpdb->insert(
            $wpdb->prefix . 'nexdirectory_private_files',
            [
                'token_hash' => $token_hash,
                'storage_path' => $destination,
                'original_name' => sanitize_file_name((string) $original_name),
                'mime_type' => 'application/pdf',
                'file_size' => absint((int) filesize($destination)),
                'event_id' => absint((int) $event_id),
                'message_id' => 0,
                'owner_user_id' => absint((int) $owner_user_id),
                'recipient_user_id' => absint((int) $recipient_user_id),
                'created_at' => $created_at,
                'expires_at' => $expires_at,
            ],
            ['%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%s', '%s']
        );
        if (!$inserted) {
            wp_delete_file($destination);
            return new WP_Error('nexdirectory_private_file_record', __('The uploaded CV could not be registered securely.', 'nexdirectory'));
        }
        $file_id = absint((int) $wpdb->insert_id);
        $url = nexdirectory_add_query_arg([
            'nexdirectory_private_file' => $file_id,
            'nexdirectory_private_token' => $token,
        ], home_url('/'));
        return [
            'file_id' => $file_id,
            'url' => esc_url_raw((string) $url),
            'path' => $destination,
        ];
    }

    /**
     * Associates a private file with its inbox message after insertion.
     *
     * @param int $file_id Private file ID.
     * @param int $message_id Inbox message ID.
     * @param int $recipient_user_id Recipient user ID.
     * @return void
     */
    public function bind_private_attachment_to_message($file_id, $message_id, $recipient_user_id) {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This updates a plugin-owned private-file record.
        $wpdb->update(
            $wpdb->prefix . 'nexdirectory_private_files',
            [
                'message_id' => absint((int) $message_id),
                'recipient_user_id' => absint((int) $recipient_user_id),
            ],
            ['id' => absint((int) $file_id)],
            ['%d', '%d'],
            ['%d']
        );
    }

    /**
     * Permanently removes a private file and its record.
     *
     * @param int $file_id Private file ID.
     * @return void
     */
    public function delete_private_attachment($file_id) {
        global $wpdb;
        $file_id = absint((int) $file_id);
        if ($file_id <= 0) {
            return;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Deletion needs the current plugin-owned storage path.
        $path = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT storage_path FROM ' . $wpdb->prefix . 'nexdirectory_private_files WHERE id = %d',
                $file_id
            )
        );
        $this->delete_private_file_path((string) $path);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This deletes a plugin-owned private-file record.
        $wpdb->delete($wpdb->prefix . 'nexdirectory_private_files', ['id' => $file_id], ['%d']);
    }

    /**
     * Purges files that have reached the configured retention deadline.
     *
     * @param int $retention_days Configured retention period.
     * @return void
     */
    public function cleanup_expired_private_files($retention_days = 730) {
        global $wpdb;
        $retention_days = max(1, min(3650, absint((int) $retention_days)));
        $cutoff = gmdate('Y-m-d H:i:s', time() - ($retention_days * DAY_IN_SECONDS));
        $table = $wpdb->prefix . 'nexdirectory_private_files';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- A live schema check keeps scheduled cleanup safe after incomplete installs.
        $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
        if ((string) $exists !== $table) {
            return;
        }
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table name is plugin-owned and derived from the trusted WordPress prefix.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Retention cleanup must inspect current file paths.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, storage_path FROM {$table}
                WHERE (expires_at IS NOT NULL AND expires_at <= %s) OR created_at <= %s
                ORDER BY id ASC LIMIT 500",
                current_time('mysql', true),
                $cutoff
            ),
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        foreach ((array) $rows as $row) {
            $this->delete_private_file_path((string) ($row['storage_path'] ?? ''));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This deletes an expired plugin-owned private-file record.
            $wpdb->delete($table, ['id' => absint((int) ($row['id'] ?? 0))], ['%d']);
        }
    }

    /**
     * Returns an outside-web-root, per-site storage directory.
     *
     * @return string|WP_Error
     */
    private function get_private_storage_directory() {
        $default_base = function_exists('nexdirectory_get_upload_subdirectory')
            ? nexdirectory_get_upload_subdirectory('private')
            : '';
        if (is_wp_error($default_base) || (string) $default_base === '') {
            return new WP_Error('nexdirectory_private_storage_path', __('Private attachment storage requires the WordPress uploads directory.', 'nexdirectory'));
        }
        $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 new WP_Error('nexdirectory_private_storage_path', __('Private attachment storage requires an absolute server path.', 'nexdirectory'));
        }

        $directory = trailingslashit($base_directory) . 'site-' . get_current_blog_id();

        if (!wp_mkdir_p($directory) || !wp_is_writable($directory)) {
            return new WP_Error('nexdirectory_private_storage_unwritable', __('Private attachment storage is not writable. Ask the site administrator to check server permissions.', 'nexdirectory'));
        }

        $real_directory = realpath($directory);
        if (!$real_directory) {
            return new WP_Error('nexdirectory_private_storage_unavailable', __('Private attachment storage is not available on this server.', 'nexdirectory'));
        }
        $filesystem = function_exists('nexdirectory_get_wp_filesystem') ? nexdirectory_get_wp_filesystem() : null;
        if (is_object($filesystem) && method_exists($filesystem, 'chmod')) {
            $filesystem->chmod($real_directory, 0700);
        }
        $htaccess_file = trailingslashit($real_directory) . '.htaccess';
        if (!is_file($htaccess_file)) {
            if (is_object($filesystem) && method_exists($filesystem, 'put_contents')) {
                $filesystem->put_contents($htaccess_file, "Require all denied\nDeny from all\n", defined('FS_CHMOD_FILE') ? FS_CHMOD_FILE : 0644);
            }
        }
        return $real_directory;
    }

    /**
     * Streams an authorized private attachment.
     *
     * @param int $file_id Private file ID.
     * @return void
     */
    private function serve_private_attachment($file_id) {
        global $wpdb;
        $token = sanitize_text_field((string) get_query_var('nexdirectory_private_token'));
        if (!is_user_logged_in() || $token === '') {
            status_header(404);
            exit;
        }
        $table = $wpdb->prefix . 'nexdirectory_private_files';
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table name is plugin-owned and derived from the trusted WordPress prefix.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private downloads require a live authorization record.
        $row = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM {$table} WHERE id = %d AND token_hash = %s LIMIT 1",
                absint((int) $file_id),
                hash('sha256', $token)
            ),
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        if (!is_array($row)) {
            status_header(404);
            exit;
        }
        $expires_at = strtotime((string) ($row['expires_at'] ?? '') . ' UTC');
        if ($expires_at && $expires_at < time()) {
            $this->delete_private_attachment($file_id);
            status_header(410);
            exit;
        }
        $current_user_id = (int) get_current_user_id();
        $event_id = absint((int) ($row['event_id'] ?? 0));
        $authorized = $current_user_id === absint((int) ($row['owner_user_id'] ?? 0))
            || $current_user_id === absint((int) ($row['recipient_user_id'] ?? 0))
            || current_user_can('manage_options')
            || ($event_id > 0 && current_user_can('edit_post', $event_id));
        if (!$authorized) {
            status_header(404);
            exit;
        }

        $path = (string) ($row['storage_path'] ?? '');
        $storage_directory = $this->get_private_storage_directory();
        $real_path = $path !== '' ? realpath($path) : false;
        $real_directory = !is_wp_error($storage_directory) ? realpath((string) $storage_directory) : false;
        if (!$real_path || !$real_directory || strpos(wp_normalize_path($real_path), trailingslashit(wp_normalize_path($real_directory))) !== 0 || !is_file($real_path)) {
            status_header(404);
            exit;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This records access to a plugin-owned private-file record.
        $wpdb->update(
            $table,
            ['downloaded_at' => current_time('mysql', true)],
            ['id' => absint((int) $file_id)],
            ['%s'],
            ['%d']
        );
        $download_name = sanitize_file_name((string) ($row['original_name'] ?: 'attachment.pdf'));
        if (strtolower((string) pathinfo($download_name, PATHINFO_EXTENSION)) !== 'pdf') {
            $download_name .= '.pdf';
        }
        nocache_headers();
        header('Content-Type: application/pdf');
        header('Content-Disposition: attachment; filename="' . $download_name . '"');
        header('Content-Length: ' . (string) filesize($real_path));
        header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
        header('X-Content-Type-Options: nosniff');
        header("Content-Security-Policy: sandbox; default-src 'none'");
        header('X-Frame-Options: DENY');
        header('X-Robots-Tag: noindex, nofollow, noarchive');
        readfile($real_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- A private binary response must be streamed directly after authorization.
        exit;
    }

    /**
     * Deletes a path only when it resolves inside the private storage directory.
     *
     * @param string $path Stored path.
     * @return void
     */
    private function delete_private_file_path($path) {
        $path = (string) $path;
        $storage_directory = $this->get_private_storage_directory();
        if ($path === '' || is_wp_error($storage_directory)) {
            return;
        }
        $real_path = realpath($path);
        $real_directory = realpath((string) $storage_directory);
        if (!$real_path || !$real_directory || strpos(wp_normalize_path($real_path), trailingslashit(wp_normalize_path($real_directory))) !== 0) {
            return;
        }
        if (is_file($real_path)) {
            wp_delete_file($real_path);
        }
    }
}
