<?php

namespace CaptureRelay;

final class Admin
{
    private ?int $pendingCountCache = null;

    public function hooks(): void
    {
        add_action('admin_menu', [$this, 'menu']);
        add_action('admin_menu', [$this, 'redirectLegacyPages'], 99);
        add_action('admin_enqueue_scripts', [$this, 'assets']);
        add_action('admin_notices', [$this, 'notices']);
        add_action('admin_post_capture_relay_decide', [$this, 'decide']);
        add_action('admin_post_capture_relay_revoke', [$this, 'revoke']);
        add_action('admin_post_capture_relay_rename', [$this, 'rename']);
        add_action('admin_post_capture_relay_unblock', [$this, 'unblock']);
        add_action('admin_post_capture_relay_save_settings', [$this, 'saveSettings']);
        add_action('admin_post_capture_relay_dismiss_setup_notice', [$this, 'dismissSetupNotice']);
    }

    public function menu(): void
    {
        $capability = (string) Config::get('required_capability', 'manage_options');
        $count = current_user_can($capability) ? $this->pendingCount() : 0;
        $badge = $count > 0 ? ' <span class="awaiting-mod count-' . $count . '"><span class="pending-count">' . $count . '</span></span>' : '';
        add_management_page(
            __('CaptureRelay', 'capture-relay'),
            __('CaptureRelay', 'capture-relay') . $badge,
            $capability,
            'capture-relay',
            [$this, 'page']
        );
    }

    public function redirectLegacyPages(): void
    {
        if (!current_user_can((string) Config::get('required_capability', 'manage_options'))) {
            return;
        }
        $page = isset($_GET['page']) && is_scalar($_GET['page']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only legacy admin navigation.
            ? sanitize_key(wp_unslash((string) $_GET['page'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only legacy admin navigation.
            : '';
        $legacyTabs = [
            'capture-relay-settings' => 'settings',
            'capture-relay-activity' => 'activity',
            'capture-relay-diagnostics' => 'diagnostics',
        ];
        if (!isset($legacyTabs[$page])) {
            return;
        }
        wp_safe_redirect($this->pageUrl($legacyTabs[$page]));
        exit;
    }

    public function page(): void
    {
        $this->guard();
        $activeTab = $this->activeTab();
        echo '<div class="wrap capture-relay-admin">';
        $this->pageHeader(
            __('CaptureRelay', 'capture-relay'),
            __('Manage mobile connections, delivery settings, upload history, diagnostics, and guidance from one place.', 'capture-relay')
        );
        $this->tabs($activeTab);
        echo '<main class="capture-relay-tab-panel" id="capture-relay-tab-' . esc_attr($activeTab) . '">';
        match ($activeTab) {
            'settings' => $this->settingsPage(),
            'activity' => $this->activityPage(),
            'diagnostics' => $this->diagnosticsPage(),
            'documentation' => (new Documentation())->render(),
            default => $this->connectionsPage(),
        };
        echo '</main></div>';
    }

    public function assets(): void
    {
        $capability = (string) Config::get('required_capability', 'manage_options');
        if (current_user_can($capability) && ($this->pendingCount() > 0 || $this->setupNoticeShouldDisplay())) {
            wp_enqueue_style('capture-relay-admin-notice', CAPTURE_RELAY_URL . 'assets/notice.css', [], $this->assetVersion('assets/notice.css'));
        }

        $screen = get_current_screen();
        if (!$screen || (string) $screen->id !== 'tools_page_capture-relay') {
            return;
        }
        wp_enqueue_style('capture-relay-admin', CAPTURE_RELAY_URL . 'assets/admin.css', [], $this->assetVersion('assets/admin.css'));
    }

    public function notices(): void
    {
        if (!current_user_can((string) Config::get('required_capability', 'manage_options'))) {
            return;
        }
        if ($this->setupNoticeShouldDisplay()) {
            echo '<div class="notice notice-warning capture-relay-setup-notice"><h2>'
                . esc_html__('Finish setting up CaptureRelay before inviting app users.', 'capture-relay')
                . '</h2><p>'
                . esc_html__('Review and save the website’s image quality, upload limits, notifications, security limits, and retention settings before telling anyone they can submit images.', 'capture-relay')
                . '</p><div class="capture-relay-setup-actions"><a class="button button-primary" href="' . esc_url($this->pageUrl('settings')) . '">'
                . esc_html__('Review Settings', 'capture-relay') . '</a> <a class="button" href="' . esc_url($this->pageUrl('documentation')) . '">'
                . esc_html__('Read Documentation', 'capture-relay') . '</a> <form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            wp_nonce_field('capture_relay_dismiss_setup_notice');
            echo '<input type="hidden" name="action" value="capture_relay_dismiss_setup_notice"><button type="submit" class="button capture-relay-dismiss-setup">'
                . esc_html__('Dismiss', 'capture-relay') . '</button></form></div></div>';
        }

        $count = $this->pendingCount();
        if ($count > 0) {
            $url = $this->pageUrl('connections');
            echo '<div class="notice is-dismissible capture-relay-approval-notice"><p>' . sprintf(
                /* translators: %d: number of device approval requests. */
                esc_html(_n('CaptureRelay has %d device awaiting approval.', 'CaptureRelay has %d devices awaiting approval.', $count, 'capture-relay')),
                absint($count)
            ) . ' <a href="' . esc_url($url) . '">' . esc_html__('Review requests', 'capture-relay') . '</a></p></div>';
        }
    }

    public function connectionsPage(): void
    {
        $this->guard();
        global $wpdb;
        $requests = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i ORDER BY created_at DESC LIMIT 200', Config::table('requests')));
        $devices = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i ORDER BY created_at DESC LIMIT 200', Config::table('devices')));
        $blocks = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i ORDER BY blocked_at DESC LIMIT 200', Config::table('blocks')));
        $pending = array_filter($requests, static fn($row) => $row->status === 'pending' && strtotime($row->expires_at . ' UTC') >= time());
        $closed = array_filter($requests, static fn($row) => $row->status !== 'pending' || strtotime($row->expires_at . ' UTC') < time());

        $this->tabIntro(__('Connections', 'capture-relay'), __('Approve devices and control exactly who can deliver images to this Media Library.', 'capture-relay'));
        $this->sectionHeading(__('Pending Requests', 'capture-relay'), count($pending));
        if (!$pending) {
            echo '<p>' . esc_html__('No devices are awaiting approval.', 'capture-relay') . '</p>';
        } else {
            echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Device', 'capture-relay') . '</th><th>' . esc_html__('Platform', 'capture-relay') . '</th><th>' . esc_html__('Request source', 'capture-relay') . '</th><th>' . esc_html__('Requested', 'capture-relay') . '</th><th>' . esc_html__('Expires', 'capture-relay') . '</th><th>' . esc_html__('Actions', 'capture-relay') . '</th></tr></thead><tbody>';
            foreach ($pending as $row) {
                echo '<tr><td><strong>' . esc_html($row->device_name) . '</strong><br><small>' . esc_html($row->device_model ?: __('Model unavailable', 'capture-relay')) . ' · App ' . esc_html($row->app_version ?: '—') . '</small></td>';
                /* translators: %d is the number of devices currently approved. */
                $approvedDeviceCount = sprintf(esc_html__('%d devices currently approved', 'capture-relay'), count(array_filter($devices, static fn($device) => $device->status === 'approved')));
                echo '<td>' . esc_html(ucfirst($row->platform)) . '</td><td>' . esc_html($row->ip_address ?: __('Unavailable', 'capture-relay')) . '<br><small>' . esc_html($approvedDeviceCount) . '</small></td><td>' . esc_html(get_date_from_gmt($row->created_at, 'M j, Y g:i a')) . '</td><td>' . esc_html(get_date_from_gmt($row->expires_at, 'M j, Y g:i a')) . '</td><td>';
                $this->decisionForm($row->request_id, 'approve', __('Approve', 'capture-relay'), 'button button-primary');
                echo ' ';
                $this->decisionForm($row->request_id, 'reject', __('Reject', 'capture-relay'), 'button');
                echo ' ';
                $this->decisionForm($row->request_id, 'reject_and_block', __('Reject & Block', 'capture-relay'), 'button capture-relay-danger');
                echo '</td></tr>';
            }
            echo '</tbody></table>';
        }

        $approved = array_filter($devices, static fn($row) => $row->status === 'approved');
        $this->sectionHeading(__('Approved Devices', 'capture-relay'), count($approved));
        if (!$approved) {
            echo '<p>' . esc_html__('No devices have been approved.', 'capture-relay') . '</p>';
        } else {
            echo '<div class="capture-relay-table-scroll"><table class="widefat striped capture-relay-approved-table"><thead><tr><th>' . esc_html__('Device', 'capture-relay') . '</th><th>' . esc_html__('Platform', 'capture-relay') . '</th><th>' . esc_html__('Approved', 'capture-relay') . '</th><th>' . esc_html__('Last request', 'capture-relay') . '</th><th>' . esc_html__('Last upload', 'capture-relay') . '</th><th class="capture-relay-actions-column">' . esc_html__('Actions', 'capture-relay') . '</th></tr></thead><tbody>';
            foreach ($approved as $row) {
                echo '<tr><td><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
                wp_nonce_field('capture_relay_rename_' . $row->device_id);
                echo '<input type="hidden" name="action" value="capture_relay_rename"><input type="hidden" name="device_id" value="' . esc_attr($row->device_id) . '"><div class="capture-relay-device-name-row"><input class="capture-relay-device-name" type="text" name="display_name" value="' . esc_attr($row->display_name) . '" maxlength="160" required><button class="button">' . esc_html__('Rename', 'capture-relay') . '</button></div></form></td><td><strong class="capture-relay-platform">' . esc_html(ucfirst($row->platform)) . '</strong>';
                if (trim((string) $row->device_model) !== '') {
                    echo '<small class="capture-relay-device-model">' . esc_html($row->device_model) . '</small>';
                }
                echo '</td><td>' . esc_html(get_date_from_gmt($row->created_at, 'M j, Y')) . '</td><td>' . esc_html($this->dateOrDash($row->last_request_at)) . '</td><td>' . esc_html($this->dateOrDash($row->last_upload_at)) . '</td><td class="capture-relay-device-actions-cell"><div class="capture-relay-device-actions">';
                echo '<form class="capture-relay-inline-action" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" onsubmit="return confirm(\'' . esc_js(__('Revoke this device immediately?', 'capture-relay')) . '\')">';
                wp_nonce_field('capture_relay_revoke_' . $row->device_id);
                echo '<input type="hidden" name="action" value="capture_relay_revoke"><input type="hidden" name="device_id" value="' . esc_attr($row->device_id) . '"><button class="button capture-relay-revoke">' . esc_html__('Revoke', 'capture-relay') . '</button></form>';
                if ($row->identity_hash !== '') {
                    echo '<form class="capture-relay-inline-action" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" onsubmit="return confirm(\'' . esc_js(__('Revoke and permanently block future requests from this device?', 'capture-relay')) . '\')">';
                    wp_nonce_field('capture_relay_revoke_' . $row->device_id);
                    echo '<input type="hidden" name="action" value="capture_relay_revoke"><input type="hidden" name="device_id" value="' . esc_attr($row->device_id) . '"><input type="hidden" name="block" value="1"><button class="button capture-relay-danger">' . esc_html__('Revoke & Block', 'capture-relay') . '</button></form>';
                }
                echo '</div></td></tr>';
            }
            echo '</tbody></table></div>';
        }

        $this->sectionHeading(__('Rejected, Expired, and Revoked', 'capture-relay'), count($closed) + count($devices) - count($approved));
        echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Record', 'capture-relay') . '</th><th>' . esc_html__('Status', 'capture-relay') . '</th><th>' . esc_html__('Date', 'capture-relay') . '</th></tr></thead><tbody>';
        foreach ($closed as $row) {
            $status = ($row->status === 'pending') ? 'expired' : $row->status;
            echo '<tr><td>' . esc_html($row->device_name) . '</td><td>' . esc_html(ucfirst($status)) . '</td><td>' . esc_html(get_date_from_gmt($row->created_at, 'M j, Y g:i a')) . '</td></tr>';
        }
        foreach (array_filter($devices, static fn($row) => $row->status !== 'approved') as $row) {
            echo '<tr><td>' . esc_html($row->display_name) . '</td><td>' . esc_html(ucfirst($row->status)) . '</td><td>' . esc_html($this->dateOrDash($row->revoked_at ?: $row->created_at)) . '</td></tr>';
        }
        echo '</tbody></table>';

        $this->sectionHeading(__('Blocked Devices', 'capture-relay'), count($blocks));
        echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Device', 'capture-relay') . '</th><th>' . esc_html__('Platform', 'capture-relay') . '</th><th>' . esc_html__('Blocked', 'capture-relay') . '</th><th>' . esc_html__('Blocked attempts', 'capture-relay') . '</th><th></th></tr></thead><tbody>';
        foreach ($blocks as $row) {
            echo '<tr><td><strong>' . esc_html($row->display_name ?: __('Unknown device', 'capture-relay')) . '</strong><br><small>' . esc_html($row->reason ?: __('Blocked by an administrator', 'capture-relay')) . '</small></td><td>' . esc_html(ucfirst($row->platform)) . '</td><td>' . esc_html($this->dateOrDash($row->blocked_at)) . '</td><td>' . absint($row->hit_count) . '</td><td><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            wp_nonce_field('capture_relay_unblock_' . $row->identity_hash);
            echo '<input type="hidden" name="action" value="capture_relay_unblock"><input type="hidden" name="identity_hash" value="' . esc_attr($row->identity_hash) . '"><button class="button">' . esc_html__('Unblock', 'capture-relay') . '</button></form></td></tr>';
        }
        if (!$blocks) {
            echo '<tr><td colspan="5">' . esc_html__('No devices are permanently blocked.', 'capture-relay') . '</td></tr>';
        }
        echo '</tbody></table>';
    }

    public function decide(): void
    {
        $this->guard();
        $requestId = sanitize_text_field(wp_unslash($_POST['request_id'] ?? ''));
        $decision = sanitize_key(wp_unslash($_POST['decision'] ?? ''));
        check_admin_referer('capture_relay_decide_' . $requestId);
        if (!in_array($decision, ['approve', 'reject', 'reject_and_block'], true)) {
            wp_die(esc_html__('Invalid decision.', 'capture-relay'));
        }

        global $wpdb;
        $wpdb->query('START TRANSACTION');
        $request = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE request_id = %s FOR UPDATE',
            Config::table('requests'),
            $requestId
        ));
        if (!$request || $request->status !== 'pending' || strtotime($request->expires_at . ' UTC') < time()) {
            $wpdb->query('ROLLBACK');
            $this->redirect('request_unavailable');
        }

        if ($decision === 'approve') {
            $priorDeviceIds = $wpdb->get_col($wpdb->prepare(
                'SELECT DISTINCT d.device_id FROM %i d INNER JOIN %i r ON r.device_id = d.device_id WHERE r.device_uuid = %s AND d.status = %s FOR UPDATE',
                Config::table('devices'),
                Config::table('requests'),
                $request->device_uuid,
                'approved'
            ));
            foreach ($priorDeviceIds as $priorDeviceId) {
                $wpdb->update(Config::table('devices'), [
                    'status' => 'revoked',
                    'credential_cipher' => CredentialVault::seal(Security::randomToken(48)),
                    'revoked_at' => current_time('mysql', true),
                    'revoked_by' => get_current_user_id(),
                ], ['device_id' => $priorDeviceId], ['%s', '%s', '%s', '%d'], ['%s']);
            }
            $deviceId = Security::randomToken(24);
            $credential = Security::randomToken(48);
            $created = $wpdb->insert(Config::table('devices'), [
                'device_id' => $deviceId, 'website_id' => Security::websiteId(),
                'credential_hash' => password_hash($credential, PASSWORD_DEFAULT),
                'credential_cipher' => CredentialVault::seal($credential),
                'display_name' => $request->device_name, 'platform' => $request->platform,
                'device_model' => $request->device_model, 'app_version' => $request->app_version,
                'identity_hash' => $request->identity_hash,
                'status' => 'approved', 'created_at' => current_time('mysql', true),
            ], ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
            unset($credential);
            if (!$created) {
                $wpdb->query('ROLLBACK');
                $this->redirect('approval_failed');
            }
            $wpdb->update(Config::table('requests'), [
                'status' => 'approved', 'device_id' => $deviceId, 'decided_at' => current_time('mysql', true), 'decided_by' => get_current_user_id(), 'ip_address' => '',
            ], ['request_id' => $requestId], ['%s', '%s', '%s', '%d', '%s'], ['%s']);
            Log::write('connection_approved', 'An administrator approved a mobile device.', ['request_id' => $requestId, 'device_id' => $deviceId]);
            foreach ($priorDeviceIds as $priorDeviceId) {
                Log::write('device_rotated', 'A prior credential for this device was revoked during reconnection.', ['device_id' => $priorDeviceId]);
            }
        } else {
            if ($decision === 'reject_and_block') {
                DeviceBlocklist::blockRequest($request, __('Rejected and blocked by an administrator.', 'capture-relay'));
            }
            $wpdb->update(Config::table('requests'), [
                'status' => 'rejected', 'decided_at' => current_time('mysql', true), 'decided_by' => get_current_user_id(), 'ip_address' => '',
            ], ['request_id' => $requestId], ['%s', '%s', '%d', '%s'], ['%s']);
            Log::write($decision === 'reject_and_block' ? 'connection_blocked' : 'connection_rejected', $decision === 'reject_and_block' ? 'An administrator rejected and blocked a mobile device.' : 'An administrator rejected a mobile device.', ['request_id' => $requestId]);
        }
        $wpdb->query('COMMIT');
        $this->redirect($decision === 'approve' ? 'approved' : ($decision === 'reject_and_block' ? 'blocked' : 'rejected'));
    }

    public function revoke(): void
    {
        $this->guard();
        $deviceId = sanitize_text_field(wp_unslash($_POST['device_id'] ?? ''));
        check_admin_referer('capture_relay_revoke_' . $deviceId);
        global $wpdb;
        $device = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE device_id = %s LIMIT 1',
            Config::table('devices'),
            $deviceId
        ));
        $shouldBlock = sanitize_key(wp_unslash($_POST['block'] ?? '0')) === '1';
        if ($shouldBlock && $device) {
            DeviceBlocklist::blockDevice($device, __('Revoked and blocked by an administrator.', 'capture-relay'));
        }
        $wpdb->update(Config::table('devices'), [
            'status' => 'revoked', 'credential_cipher' => CredentialVault::seal(Security::randomToken(48)),
            'revoked_at' => current_time('mysql', true), 'revoked_by' => get_current_user_id(),
        ], ['device_id' => $deviceId], ['%s', '%s', '%s', '%d'], ['%s']);
        Log::write($shouldBlock ? 'device_revoked_blocked' : 'device_revoked', $shouldBlock ? 'An administrator revoked and blocked a mobile device.' : 'An administrator revoked a mobile device.', ['device_id' => $deviceId], 'warning');
        $this->redirect($shouldBlock ? 'revoked_blocked' : 'revoked');
    }

    public function unblock(): void
    {
        $this->guard();
        $identityHash = sanitize_text_field(wp_unslash($_POST['identity_hash'] ?? ''));
        check_admin_referer('capture_relay_unblock_' . $identityHash);
        if (preg_match('/^[a-f0-9]{64}$/', $identityHash)) {
            DeviceBlocklist::unblock($identityHash);
            Log::write('device_unblocked', 'An administrator removed a permanent device block.', ['identity_hash' => $identityHash]);
        }
        $this->redirect('unblocked');
    }

    public function rename(): void
    {
        $this->guard();
        $deviceId = sanitize_text_field(wp_unslash($_POST['device_id'] ?? ''));
        check_admin_referer('capture_relay_rename_' . $deviceId);
        $displayName = substr(sanitize_text_field(wp_unslash($_POST['display_name'] ?? '')), 0, 160);
        if ($displayName !== '') {
            global $wpdb;
            $wpdb->update(Config::table('devices'), ['display_name' => $displayName], ['device_id' => $deviceId], ['%s'], ['%s']);
            Log::write('device_renamed', 'An administrator renamed a mobile device.', ['device_id' => $deviceId]);
        }
        $this->redirect('renamed');
    }

    public function settingsPage(): void
    {
        $this->guard();
        $settings = Config::all();
        $settings['erase_on_uninstall'] = (bool) get_option('capture_relay_erase_on_uninstall', false);
        $this->tabIntro(__('Settings', 'capture-relay'), __('Tune image delivery, notifications, security limits, and retention for this website.', 'capture-relay'));
        echo '<form class="capture-relay-settings" method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
        wp_nonce_field('capture_relay_save_settings');
        echo '<input type="hidden" name="action" value="capture_relay_save_settings"><table class="form-table"><tbody>';
        $this->settingsSection(__('Images', 'capture-relay'), __('Set the exact Media Library output and safeguards for every image delivered to this website.', 'capture-relay'));
        $this->number('required_image_width', __('Required image width (px)', 'capture-relay'), $settings, Config::MIN_IMAGE_DIMENSION, Config::MAX_IMAGE_DIMENSION, __('The exact width CaptureRelay apps use for images delivered to this website. The width must be greater than the height.', 'capture-relay'));
        $this->number('required_image_height', __('Required image height (px)', 'capture-relay'), $settings, Config::MIN_IMAGE_DIMENSION, Config::MAX_IMAGE_DIMENSION, __('The exact height CaptureRelay apps use for images delivered to this website. Approved apps refresh this requirement automatically.', 'capture-relay'));
        $webpSupported = wp_image_editor_supports(['mime_type' => 'image/webp']);
        $this->buttonSet('output_image_format', __('Media Library image format', 'capture-relay'), $settings, [
            'jpeg' => __('JPEG (JPG)', 'capture-relay'),
            'webp' => __('WebP', 'capture-relay'),
        ], $webpSupported
            ? __('Choose the final file format saved in the WordPress Media Library. CaptureRelay always receives a signed JPEG from the app, then safely rewrites it in this format.', 'capture-relay')
            : __('Choose the final file format saved in the WordPress Media Library. WebP is unavailable because this server’s WordPress image engine cannot create WebP files. JPEG remains available.', 'capture-relay'), $webpSupported ? [] : ['webp']);
        $this->number('jpeg_quality', __('Image quality (%)', 'capture-relay'), $settings, 30, 95, __('Compression quality applied while preparing this website’s final JPEG or WebP image. Lower values produce smaller, faster-loading files; 75–85 is a practical range for web images.', 'capture-relay'));
        $this->number('maximum_upload_kb', __('Maximum upload size (KB)', 'capture-relay'), ['maximum_upload_kb' => max(50, round($settings['maximum_upload_bytes'] / 1024))], 50, 10240, __('Hard file-size ceiling after compression. Files above this limit stay in the app’s Upload Queue and are not accepted by WordPress.', 'capture-relay'));
        $this->text('caption_format', __('Photo Credit caption format', 'capture-relay'), $settings, 'Photo by {credit}', __('Controls the WordPress media caption. Include {credit} where the photographer or source name should appear.', 'capture-relay'));
        $this->text('default_copyright', __('Default copyright value', 'capture-relay'), $settings, '', __('Optional copyright text stored as attachment metadata on every uploaded image.', 'capture-relay'));

        $this->settingsSection(__('Connections', 'capture-relay'), __('Control which devices may connect and deliver images, with limits that protect the approval workflow.', 'capture-relay'));
        $this->checkbox('uploads_enabled', __('Enable mobile uploads', 'capture-relay'), $settings, __('Allows already approved devices to deliver images to this website. Disabling uploads does not revoke devices or delete queued images.', 'capture-relay'));
        $this->checkbox('new_requests_enabled', __('Accept new connection requests', 'capture-relay'), $settings, __('Allows new devices to request administrator approval. Disable this without interrupting uploads from devices that are already approved.', 'capture-relay'));
        $this->number('request_expiration_hours', __('Request expiration (hours)', 'capture-relay'), $settings, 1, 168, __('How long an unapproved device request remains valid before the user must request access again.', 'capture-relay'));
        $this->number('maximum_pending_requests', __('Maximum pending requests', 'capture-relay'), $settings, 1, 200, __('Maximum number of unapproved device requests the site will retain at one time. This helps prevent abuse.', 'capture-relay'));
        $this->number('requests_per_hour_ip', __('Connection requests per IP per hour', 'capture-relay'), $settings, 1, 100, __('Rate limit for new connection requests coming from the same internet address.', 'capture-relay'));
        $this->number('requests_per_hour_device', __('Connection requests per device per hour', 'capture-relay'), $settings, 1, 25, __('Rate limit for repeated connection requests from the same phone or tablet.', 'capture-relay'));
        $this->number('requests_per_hour_global', __('Connection requests site-wide per hour', 'capture-relay'), $settings, 1, 1000, __('A site-wide ceiling that continues to protect the approval queue when traffic comes from many internet addresses.', 'capture-relay'));
        $this->number('pairing_requests_per_hour_ip', __('Approval checks per IP per hour', 'capture-relay'), $settings, 60, 10000, __('Limits approval polling, credential completion, and request cancellation traffic before database lookups.', 'capture-relay'));
        $this->number('uploads_per_hour_device', __('Uploads per device per hour', 'capture-relay'), $settings, 1, 1000, __('Rate limit for the number of images one approved device may submit in an hour.', 'capture-relay'));
        $this->number('media_requests_per_hour_ip', __('Rejected authentication attempts per IP per hour', 'capture-relay'), $settings, 1, 5000, __('Temporarily blocks repeated invalid signed requests from one internet address without penalizing successful uploads from other approved devices sharing that address.', 'capture-relay'));

        $this->settingsSection(__('Email', 'capture-relay'), __('Choose which administrator notifications are sent and who receives them.', 'capture-relay'));
        $this->checkbox('approval_emails', __('Send approval-request emails', 'capture-relay'), $settings, __('Emails the notification recipients when a new device asks to connect.', 'capture-relay'));
        $this->checkbox('upload_emails', __('Send an email after each image upload', 'capture-relay'), $settings, __('Emails the notification recipients after a new image is successfully added to the WordPress Media Library. Automatic retries do not send duplicate emails.', 'capture-relay'));
        $this->checkbox('repeat_request_emails', __('Email again for repeated device requests', 'capture-relay'), $settings, __('When disabled, a new request from the same device within 24 hours still appears under Connections but does not send another approval email.', 'capture-relay'));
        $this->text('notification_recipients', __('Notification recipients', 'capture-relay'), $settings, 'email1@example.com, email2@example.com', __('One or more email addresses separated by commas or spaces. These addresses receive the enabled approval and upload notifications.', 'capture-relay'));

        $this->settingsSection(__('Logging', 'capture-relay'), __('Choose how long operational records remain available and what happens if this plugin is deleted.', 'capture-relay'));
        $this->number('request_retention_days', __('Closed-request retention (days)', 'capture-relay'), $settings, 1, 365, __('How long approved, rejected, cancelled, and expired connection-request records are retained.', 'capture-relay'));
        $this->number('activity_retention_days', __('Activity retention (days)', 'capture-relay'), $settings, 7, 365, __('How long connection, upload, security, and error events remain visible in plugin activity and diagnostics.', 'capture-relay'));
        $this->number('upload_record_retention_days', __('Upload-record retention (days)', 'capture-relay'), $settings, 7, 365, __('How long the plugin keeps its delivery ledger. WordPress Media Library images are not deleted.', 'capture-relay'));
        $this->checkbox('erase_on_uninstall', __('Erase plugin data when uninstalled', 'capture-relay'), $settings, __('When enabled, deleting the plugin permanently removes CaptureRelay settings, connection records, credentials, blocks, and activity. Media Library images remain.', 'capture-relay'));
        echo '</tbody></table>'; submit_button(); echo '</form>';
    }

    public function saveSettings(): void
    {
        $this->guard();
        check_admin_referer('capture_relay_save_settings');
        $old = Config::all();
        $caption = sanitize_text_field(wp_unslash($_POST['caption_format'] ?? 'Photo by {credit}'));
        if (!str_contains($caption, '{credit}')) {
            $caption = 'Photo by {credit}';
        }
        $recipientInput = wp_unslash($_POST['notification_recipients'] ?? ''); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized after the scalar check below.
        $recipientInput = is_scalar($recipientInput) ? sanitize_text_field((string) $recipientInput) : '';
        $emails = array_filter(array_map('sanitize_email', preg_split('/[,\s]+/', $recipientInput)));
        $requiredWidth = $this->boundedInt('required_image_width', Config::MIN_IMAGE_DIMENSION, Config::MAX_IMAGE_DIMENSION);
        $requiredHeight = $this->boundedInt('required_image_height', Config::MIN_IMAGE_DIMENSION, Config::MAX_IMAGE_DIMENSION);
        if (Config::normalizeImageSize($requiredWidth . 'x' . $requiredHeight) === null) {
            $requiredWidth = (int) $old['required_image_width'];
            $requiredHeight = (int) $old['required_image_height'];
        }
        $outputFormat = sanitize_key((string) wp_unslash($_POST['output_image_format'] ?? 'jpeg')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified at the beginning of this method.
        if (!in_array($outputFormat, ['jpeg', 'webp'], true)) {
            $outputFormat = 'jpeg';
        }
        if ($outputFormat === 'webp' && !wp_image_editor_supports(['mime_type' => 'image/webp'])) {
            $outputFormat = 'jpeg';
        }
        $settings = array_merge($old, [
            'uploads_enabled' => $this->enabledSetting('uploads_enabled'),
            'new_requests_enabled' => $this->enabledSetting('new_requests_enabled'),
            'approval_emails' => $this->enabledSetting('approval_emails'),
            'upload_emails' => $this->enabledSetting('upload_emails'),
            'repeat_request_emails' => $this->enabledSetting('repeat_request_emails'),
            'notification_recipients' => implode(', ', $emails),
            'request_expiration_hours' => $this->boundedInt('request_expiration_hours', 1, 168),
            'request_retention_days' => $this->boundedInt('request_retention_days', 1, 365),
            'maximum_pending_requests' => $this->boundedInt('maximum_pending_requests', 1, 200),
            'requests_per_hour_ip' => $this->boundedInt('requests_per_hour_ip', 1, 100),
            'requests_per_hour_device' => $this->boundedInt('requests_per_hour_device', 1, 25),
            'requests_per_hour_global' => $this->boundedInt('requests_per_hour_global', 1, 1000),
            'pairing_requests_per_hour_ip' => $this->boundedInt('pairing_requests_per_hour_ip', 60, 10000),
            'required_image_width' => $requiredWidth,
            'required_image_height' => $requiredHeight,
            'output_image_format' => $outputFormat,
            'jpeg_quality' => $this->boundedInt('jpeg_quality', 30, 95),
            'maximum_upload_bytes' => $this->boundedInt('maximum_upload_kb', 50, 10240) * 1024,
            'caption_format' => $caption,
            'default_copyright' => substr(sanitize_text_field(wp_unslash($_POST['default_copyright'] ?? '')), 0, 160),
            'uploads_per_hour_device' => $this->boundedInt('uploads_per_hour_device', 1, 1000),
            'media_requests_per_hour_ip' => $this->boundedInt('media_requests_per_hour_ip', 1, 5000),
            'activity_retention_days' => $this->boundedInt('activity_retention_days', 7, 365),
            'upload_record_retention_days' => $this->boundedInt('upload_record_retention_days', 7, 365),
        ]);
        update_option('capture_relay_settings', $settings, false);
        update_option('capture_relay_erase_on_uninstall', $this->enabledSetting('erase_on_uninstall'), false);
        delete_option('capture_relay_needs_setup');
        Log::write('settings_updated', 'An administrator updated CaptureRelay settings.');
        wp_safe_redirect($this->pageUrl('settings', ['updated' => '1']));
        exit;
    }

    public function dismissSetupNotice(): void
    {
        $this->guard();
        check_admin_referer('capture_relay_dismiss_setup_notice');
        delete_option('capture_relay_needs_setup');
        wp_safe_redirect($this->pageUrl('connections', ['setup_dismissed' => '1']));
        exit;
    }

    private function setupNoticeShouldDisplay(): bool
    {
        if (!(bool) get_option('capture_relay_needs_setup', false)) {
            return false;
        }

        $activatedAt = (int) get_option('capture_relay_activated_at', 0);
        if ($activatedAt <= 0) {
            // Older installations did not record activation time. Start the
            // fourteen-day courtesy window when this version first sees them.
            update_option('capture_relay_activated_at', time(), false);
            return true;
        }

        if ($activatedAt + (14 * DAY_IN_SECONDS) > time()) {
            return true;
        }

        delete_option('capture_relay_needs_setup');
        return false;
    }

    public function activityPage(): void
    {
        $this->guard();
        global $wpdb;
        $uploads = $wpdb->get_results($wpdb->prepare(
            'SELECT u.*, d.display_name FROM %i u LEFT JOIN %i d ON d.device_id = u.device_id ORDER BY u.created_at DESC LIMIT 250',
            Config::table('uploads'),
            Config::table('devices')
        ));
        $this->tabIntro(__('Upload Activity', 'capture-relay'), __('A concise audit trail of images delivered through CaptureRelay.', 'capture-relay'));
        echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Image', 'capture-relay') . '</th><th>' . esc_html__('Photo Credit', 'capture-relay') . '</th><th>' . esc_html__('Device', 'capture-relay') . '</th><th>' . esc_html__('Date', 'capture-relay') . '</th><th>' . esc_html__('Status', 'capture-relay') . '</th></tr></thead><tbody>';
        foreach ($uploads as $row) {
            $title = esc_html($row->image_title ?: __('Untitled image', 'capture-relay'));
            if ($row->attachment_id) {
                $title = '<a href="' . esc_url(get_edit_post_link((int) $row->attachment_id)) . '">' . $title . '</a> (#' . absint($row->attachment_id) . ')';
            }
            echo '<tr><td>' . wp_kses_post($title) . '</td><td>' . esc_html($row->photo_credit ?: '—') . '</td><td>' . esc_html($row->display_name ?: $row->device_id) . '</td><td>' . esc_html(get_date_from_gmt($row->created_at, 'M j, Y g:i a')) . '</td><td>' . esc_html(ucfirst($row->status));
            if ($row->error_message) { echo '<br><small>' . esc_html($row->error_message) . '</small>'; }
            echo '</td></tr>';
        }
        if (!$uploads) { echo '<tr><td colspan="5">' . esc_html__('No mobile uploads have been recorded.', 'capture-relay') . '</td></tr>'; }
        echo '</tbody></table>';
    }

    public function diagnosticsPage(): void
    {
        $this->guard();
        $upload = wp_upload_dir();
        global $wpdb;
        $credentialHealth = ['valid' => 0, 'invalid' => 0];
        $approvedCredentials = $wpdb->get_results($wpdb->prepare(
            "SELECT device_id, credential_cipher FROM %i WHERE status = 'approved'",
            Config::table('devices')
        ));
        foreach ($approvedCredentials as $approvedCredential) {
            try {
                CredentialVault::open((string) $approvedCredential->credential_cipher);
                ++$credentialHealth['valid'];
            } catch (\RuntimeException $exception) {
                ++$credentialHealth['invalid'];
            }
        }
        $tests = [
            __('REST API route', 'capture-relay') => rest_url(Config::API_NAMESPACE . '/status'),
            __('Mobile uploads', 'capture-relay') => Config::get('uploads_enabled') ? __('Enabled', 'capture-relay') : __('Disabled', 'capture-relay'),
            __('New connection requests', 'capture-relay') => Config::get('new_requests_enabled') ? __('Accepted', 'capture-relay') : __('Not accepted', 'capture-relay'),
            __('HTTPS', 'capture-relay') => is_ssl() ? __('Active', 'capture-relay') : __('Not active', 'capture-relay'),
            __('Upload directory', 'capture-relay') => empty($upload['error']) && wp_is_writable($upload['basedir']) ? __('Writable', 'capture-relay') : (string) ($upload['error'] ?: __('Not writable', 'capture-relay')),
            __('PHP upload_max_filesize', 'capture-relay') => ini_get('upload_max_filesize'),
            __('PHP post_max_size', 'capture-relay') => ini_get('post_max_size'),
            __('Image processing', 'capture-relay') => extension_loaded('imagick') ? 'Imagick' : (extension_loaded('gd') ? 'GD' : __('Unavailable', 'capture-relay')),
            __('Official app verification', 'capture-relay') => $this->appProofStatus(),
            /* translators: 1: valid approved device credential count, 2: invalid approved device credential count. */
            __('Approved credential health', 'capture-relay') => sprintf(__('%1$d valid, %2$d invalid', 'capture-relay'), $credentialHealth['valid'], $credentialHealth['invalid']),
            __('Plugin version', 'capture-relay') => CAPTURE_RELAY_VERSION,
            __('REST namespace', 'capture-relay') => Config::API_NAMESPACE,
            __('Server time (UTC)', 'capture-relay') => gmdate('c'),
        ];
        $errors = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM %i WHERE severity IN ('warning','error') ORDER BY created_at DESC LIMIT 20",
            Config::table('activity')
        ));
        $this->tabIntro(__('Diagnostics', 'capture-relay'), __('Verify that WordPress, hosting, image processing, and the CaptureRelay API are ready.', 'capture-relay'));
        echo '<table class="widefat striped"><tbody>';
        foreach ($tests as $label => $value) { echo '<tr><th>' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>'; }
        echo '</tbody></table><h2>' . esc_html__('Recent Errors', 'capture-relay') . '</h2><table class="widefat striped"><thead><tr><th>' . esc_html__('Time', 'capture-relay') . '</th><th>' . esc_html__('Event', 'capture-relay') . '</th><th>' . esc_html__('Message', 'capture-relay') . '</th></tr></thead><tbody>';
        foreach ($errors as $row) { echo '<tr><td>' . esc_html(get_date_from_gmt($row->created_at, 'M j, Y g:i a')) . '</td><td>' . esc_html($row->event_type) . '</td><td>' . esc_html($row->message) . '</td></tr>'; }
        if (!$errors) { echo '<tr><td colspan="3">' . esc_html__('No recent errors.', 'capture-relay') . '</td></tr>'; }
        echo '</tbody></table>';
    }

    public static function sendApprovalEmail(string $deviceName, string $platform): void
    {
        if (!Config::get('approval_emails')) { return; }
        $recipients = array_filter(array_map('sanitize_email', preg_split('/[,\s]+/', (string) Config::get('notification_recipients', ''))));
        if (!$recipients) { return; }
        $siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
        $reviewUrl = admin_url('tools.php?page=capture-relay&tab=connections');
        /* translators: %s is the WordPress website name. */
        $subject = sprintf(__('[%s] Mobile device approval requested', 'capture-relay'), $siteName);
        $message = self::approvalEmailHtml(
            $siteName,
            $deviceName,
            ucfirst($platform),
            wp_date(get_option('date_format') . ' ' . get_option('time_format'), time(), wp_timezone()),
            $reviewUrl
        );
        wp_mail($recipients, $subject, $message, ['Content-Type: text/html; charset=UTF-8']);
    }

    private static function approvalEmailHtml(string $siteName, string $deviceName, string $platform, string $requestedAt, string $reviewUrl): string
    {
        return self::brandedEmailHtml(
            __('Mobile device approval requested', 'capture-relay'),
            __('Device approval requested', 'capture-relay'),
            $siteName,
            __('A mobile device requested permission to deliver images to this website.', 'capture-relay'),
            [
                __('Device', 'capture-relay') => $deviceName,
                __('Platform', 'capture-relay') => $platform,
                __('Requested', 'capture-relay') => $requestedAt,
            ],
            __('Review Request', 'capture-relay'),
            $reviewUrl,
            __('Security reminder:', 'capture-relay'),
            __('Approve only devices you recognize.', 'capture-relay'),
            'warning'
        );
    }

    /**
     * @param array<string, string> $details
     */
    private static function brandedEmailHtml(
        string $documentTitle,
        string $headline,
        string $siteName,
        string $intro,
        array $details,
        string $actionLabel,
        string $actionUrl,
        string $noticeTitle = '',
        string $noticeText = '',
        string $noticeType = 'success',
        string $contentHtml = ''
    ): string {
        $detailsHtml = '';
        $detailCount = count($details);
        $detailIndex = 0;
        foreach ($details as $label => $value) {
            ++$detailIndex;
            $detailsHtml .= self::emailDetailRow((string) $label, (string) $value, $detailIndex === $detailCount);
        }

        $noticeHtml = '';
        if ($noticeTitle !== '' || $noticeText !== '') {
            $noticeColors = $noticeType === 'warning'
                ? ['#fff4f4', '#d40b0f', '#6d2528']
                : ['#effaf4', '#1f9d55', '#20663f'];
            $noticeHtml = '<div style="padding:14px 16px;background:' . $noticeColors[0] . ';border-left:4px solid ' . $noticeColors[1] . ';border-radius:5px;color:' . $noticeColors[2] . ';font-size:14px;line-height:1.5;">'
                . ($noticeTitle !== '' ? '<strong>' . esc_html($noticeTitle) . '</strong> ' : '')
                . esc_html($noticeText) . '</div>';
        }

        return '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>'
            . esc_html($documentTitle)
            . '</title></head><body style="margin:0;padding:0;background:#f3f4f6;color:#202735;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">'
            . '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f3f4f6;"><tr><td align="center" style="padding:28px 12px;">'
            . '<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;background:#ffffff;border:1px solid #e3e6eb;border-radius:12px;overflow:hidden;">'
            . '<tr><td align="center" style="padding:32px 30px 25px;border-bottom:1px solid #e3e6eb;">'
            . '<img src="' . esc_url(CAPTURE_RELAY_URL . 'assets/capture-relay-logo.png') . '" width="230" alt="CaptureRelay" style="display:block;width:230px;max-width:100%;height:auto;border:0;margin:0 auto 22px;">'
            . '<h1 style="margin:0 0 7px;font-size:25px;line-height:1.25;color:#202735;">' . esc_html($headline) . '</h1>'
            . '<p style="margin:0;color:#687184;font-size:15px;line-height:1.5;">' . esc_html($siteName) . '</p></td></tr>'
            . '<tr><td style="padding:28px 32px 32px;">'
            . '<p style="margin:0 0 22px;font-size:16px;line-height:1.6;color:#394150;">' . esc_html($intro) . '</p>'
            . $contentHtml
            . '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;border:1px solid #e3e6eb;border-radius:8px;background:#fafafa;">'
            . $detailsHtml
            . '</table>'
            . '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td align="center" style="padding:26px 0 22px;">'
            . '<a href="' . esc_url($actionUrl) . '" style="display:inline-block;padding:13px 25px;background:#d40b0f;color:#ffffff;text-decoration:none;font-weight:700;font-size:16px;line-height:1;border-radius:7px;">' . esc_html($actionLabel) . '</a>'
            . '</td></tr></table>'
            . $noticeHtml
            . '<p style="margin:24px 0 0;color:#8a92a1;font-size:12px;line-height:1.5;text-align:center;">'
            . sprintf(esc_html__('Sent by CaptureRelay for %s.', 'capture-relay'), esc_html($siteName))
            . '</p></td></tr></table></td></tr></table></body></html>';
    }

    private static function emailDetailRow(string $label, string $value, bool $last = false): string
    {
        $border = $last ? '' : 'border-bottom:1px solid #e3e6eb;';
        return '<tr><td style="padding:13px 16px;' . $border . 'color:#687184;font-size:13px;font-weight:700;width:105px;">'
            . esc_html($label) . '</td><td style="padding:13px 16px;' . $border . 'color:#202735;font-size:14px;">'
            . esc_html($value) . '</td></tr>';
    }

    public static function sendUploadEmail(int $attachmentId, string $title, string $deviceName, int $bytes): void
    {
        if (!Config::get('upload_emails')) { return; }
        $recipients = array_filter(array_map('sanitize_email', preg_split('/[,\s]+/', (string) Config::get('notification_recipients', ''))));
        if (!$recipients) { return; }
        $siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
        /* translators: %s is the WordPress website name. */
        $subject = sprintf(__('[%s] Mobile image uploaded', 'capture-relay'), $siteName);
        $reviewUrl = admin_url('upload.php?item=' . $attachmentId);
        $previewUrl = wp_get_attachment_image_url($attachmentId, 'large');
        if (!$previewUrl) {
            $previewUrl = wp_get_attachment_url($attachmentId);
        }
        $message = self::uploadEmailHtml(
            $siteName,
            $title !== '' ? $title : __('Untitled image', 'capture-relay'),
            $deviceName,
            number_format_i18n(max(1, (int) ceil($bytes / 1024))) . ' ' . __('KB', 'capture-relay'),
            wp_date(get_option('date_format') . ' ' . get_option('time_format'), time(), wp_timezone()),
            $reviewUrl,
            is_string($previewUrl) ? $previewUrl : ''
        );
        wp_mail($recipients, $subject, $message, ['Content-Type: text/html; charset=UTF-8']);
    }

    private static function uploadEmailHtml(string $siteName, string $title, string $deviceName, string $fileSize, string $uploadedAt, string $reviewUrl, string $previewUrl = ''): string
    {
        $previewHtml = '';
        if ($previewUrl !== '') {
            $previewHtml = '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin:0 0 22px;">'
                . '<tr><td align="center">'
                . '<a href="' . esc_url($reviewUrl) . '" style="display:block;text-decoration:none;">'
                . '<img src="' . esc_url($previewUrl) . '" alt="' . esc_attr($title) . '" width="536" style="display:block;width:100%;max-width:536px;height:auto;border:1px solid #e3e6eb;border-radius:8px;">'
                . '</a>'
                . '<p style="margin:9px 0 0;color:#687184;font-size:12px;line-height:1.5;">' . esc_html__('Select the image to review it in the WordPress Media Library.', 'capture-relay') . '</p>'
                . '</td></tr></table>';
        }

        return self::brandedEmailHtml(
            __('Mobile image uploaded', 'capture-relay'),
            __('New image delivered', 'capture-relay'),
            $siteName,
            __('A new image was added to this website’s WordPress Media Library.', 'capture-relay'),
            [
                __('Image title', 'capture-relay') => $title,
                __('Device', 'capture-relay') => $deviceName,
                __('File size', 'capture-relay') => $fileSize,
                __('Uploaded', 'capture-relay') => $uploadedAt,
            ],
            __('Review Image', 'capture-relay'),
            $reviewUrl,
            __('Delivery complete:', 'capture-relay'),
            __('The image is ready in the WordPress Media Library.', 'capture-relay'),
            'success',
            $previewHtml
        );
    }

    private function decisionForm(string $requestId, string $decision, string $label, string $class): void
    {
        echo '<form style="display:inline" method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
        wp_nonce_field('capture_relay_decide_' . $requestId);
        echo '<input type="hidden" name="action" value="capture_relay_decide"><input type="hidden" name="request_id" value="' . esc_attr($requestId) . '"><input type="hidden" name="decision" value="' . esc_attr($decision) . '"><button class="' . esc_attr($class) . '">' . esc_html($label) . '</button></form>';
    }

    private function pendingCount(): int
    {
        if ($this->pendingCountCache !== null) {
            return $this->pendingCountCache;
        }

        global $wpdb;
        $this->pendingCountCache = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM %i WHERE status = 'pending' AND expires_at >= %s",
            Config::table('requests'),
            current_time('mysql', true)
        ));
        return $this->pendingCountCache;
    }

    private function guard(): void
    {
        if (!current_user_can((string) Config::get('required_capability', 'manage_options'))) {
            wp_die(
                esc_html__('You do not have permission to manage CaptureRelay.', 'capture-relay'),
                esc_html__('Access denied', 'capture-relay'),
                ['response' => 403]
            );
        }
    }

    private function redirect(string $message, string $tab = 'connections'): never
    {
        wp_safe_redirect($this->pageUrl($tab, ['message' => $message]));
        exit;
    }

    private function activeTab(): string
    {
        $tab = isset($_GET['tab']) && is_scalar($_GET['tab']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab navigation.
            ? sanitize_key(wp_unslash((string) $_GET['tab'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab navigation.
            : 'connections';
        return in_array($tab, ['connections', 'settings', 'activity', 'diagnostics', 'documentation'], true)
            ? $tab
            : 'connections';
    }

    private function tabs(string $activeTab): void
    {
        $tabs = [
            'connections' => __('Connections', 'capture-relay'),
            'settings' => __('Settings', 'capture-relay'),
            'activity' => __('Upload Activity', 'capture-relay'),
            'diagnostics' => __('Diagnostics', 'capture-relay'),
            'documentation' => __('Documentation', 'capture-relay'),
        ];
        echo '<nav class="nav-tab-wrapper capture-relay-tabs" aria-label="' . esc_attr__('CaptureRelay sections', 'capture-relay') . '">';
        foreach ($tabs as $tab => $label) {
            $class = 'nav-tab' . ($tab === $activeTab ? ' nav-tab-active' : '');
            echo '<a class="' . esc_attr($class) . '" href="' . esc_url($this->pageUrl($tab)) . '"'
                . ($tab === $activeTab ? ' aria-current="page"' : '') . '>' . esc_html($label);
            if ($tab === 'connections' && $this->pendingCount() > 0) {
                echo ' <span class="capture-relay-tab-count">' . absint($this->pendingCount()) . '</span>';
            }
            echo '</a>';
        }
        echo '</nav>';
    }

    private function pageUrl(string $tab, array $args = []): string
    {
        return add_query_arg(
            array_merge(['page' => 'capture-relay', 'tab' => $tab], $args),
            admin_url('tools.php')
        );
    }

    private function tabIntro(string $title, string $description): void
    {
        echo '<header class="capture-relay-tab-intro"><h2>' . esc_html($title) . '</h2><p>' . esc_html($description) . '</p></header>';
    }

    private function sectionHeading(string $title, int $count): void
    {
        echo '<h2 class="capture-relay-section-title">' . esc_html($title) . ' <span class="count">' . absint($count) . '</span></h2>';
    }

    private function pageHeader(string $title, string $subtitle): void
    {
        echo '<div class="capture-relay-hero"><img src="' . esc_url($this->assetUrl('assets/capture-relay-logo-dark.png')) . '" alt="' . esc_attr__('CaptureRelay', 'capture-relay') . '"><div><h1>' . esc_html($title) . '</h1><p>' . esc_html($subtitle) . '</p></div></div>';
    }

    private function assetUrl(string $relativePath): string
    {
        return add_query_arg('ver', $this->assetVersion($relativePath), CAPTURE_RELAY_URL . ltrim($relativePath, '/'));
    }

    private function assetVersion(string $relativePath): string
    {
        $path = CAPTURE_RELAY_DIR . ltrim($relativePath, '/');
        $modified = is_file($path) ? filemtime($path) : false;
        return CAPTURE_RELAY_VERSION . ($modified === false ? '' : '.' . $modified);
    }

    private function dateOrDash(?string $date): string
    {
        return $date ? get_date_from_gmt($date, 'M j, Y g:i a') : '—';
    }

    private function appProofStatus(): string
    {
        if (AppProof::required() && AppProof::available()) {
            return __('Required and available', 'capture-relay');
        }
        if (AppProof::required()) {
            return __('Required but no verifier is connected', 'capture-relay');
        }
        return AppProof::available()
            ? __('Verifier available but not required', 'capture-relay')
            : __('Prepared but not enforced', 'capture-relay');
    }

    private function checkbox(string $name, string $label, array $settings, string $description = ''): void
    {
        $enabled = !empty($settings[$name]);
        $enabledId = $name . '-enabled';
        $disabledId = $name . '-disabled';
        echo '<tr><th scope="row">' . esc_html($label) . '</th><td><div class="capture-relay-toggle" role="radiogroup" aria-label="' . esc_attr($label) . '">';
        echo '<input type="radio" id="' . esc_attr($enabledId) . '" name="' . esc_attr($name) . '" value="1" ' . checked($enabled, true, false) . '><label for="' . esc_attr($enabledId) . '">' . esc_html__('Enabled', 'capture-relay') . '</label>';
        echo '<input type="radio" id="' . esc_attr($disabledId) . '" name="' . esc_attr($name) . '" value="0" ' . checked($enabled, false, false) . '><label for="' . esc_attr($disabledId) . '">' . esc_html__('Disabled', 'capture-relay') . '</label></div>';
        echo wp_kses_post($this->description($description));
        echo '</td></tr>';
    }

    private function settingsSection(string $title, string $description): void
    {
        echo '<tr class="capture-relay-settings-section"><td colspan="2"><h3>' . esc_html($title) . '</h3><p>' . esc_html($description) . '</p></td></tr>';
    }

    private function text(string $name, string $label, array $settings, string $placeholder = '', string $description = ''): void
    {
        echo '<tr><th scope="row"><label for="' . esc_attr($name) . '">' . esc_html($label) . '</label></th><td><input class="regular-text capture-relay-text-control" type="text" id="' . esc_attr($name) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) ($settings[$name] ?? '')) . '" placeholder="' . esc_attr($placeholder) . '">';
        echo wp_kses_post($this->description($description));
        echo '</td></tr>';
    }

    private function number(string $name, string $label, array $settings, int $min, int $max, string $description = ''): void
    {
        echo '<tr><th scope="row"><label for="' . esc_attr($name) . '">' . esc_html($label) . '</label></th><td><input class="capture-relay-number-control" type="number" id="' . esc_attr($name) . '" name="' . esc_attr($name) . '" min="' . esc_attr((string) $min) . '" max="' . esc_attr((string) $max) . '" value="' . esc_attr((string) ($settings[$name] ?? $min)) . '">';
        echo wp_kses_post($this->description($description));
        echo '</td></tr>';
    }

    /**
     * @param array<string, string> $options
     * @param string[] $disabledValues
     */
    private function buttonSet(string $name, string $label, array $settings, array $options, string $description = '', array $disabledValues = []): void
    {
        $selected = (string) ($settings[$name] ?? array_key_first($options));
        echo '<tr><th scope="row">' . esc_html($label) . '</th><td><div class="capture-relay-toggle" role="radiogroup" aria-label="' . esc_attr($label) . '">';
        foreach ($options as $value => $optionLabel) {
            $id = $name . '-' . $value;
            echo '<input type="radio" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" ' . checked($selected, $value, false) . disabled(in_array($value, $disabledValues, true), true, false) . '><label for="' . esc_attr($id) . '">' . esc_html($optionLabel) . '</label>';
        }
        echo '</div>';
        echo wp_kses_post($this->description($description));
        echo '</td></tr>';
    }

    private function description(string $description): string
    {
        return $description === '' ? '' : '<p class="description">' . esc_html($description) . '</p>';
    }

    private function boundedInt(string $name, int $min, int $max): int
    {
        // The caller verifies capture_relay_save_settings immediately before
        // reading these dynamic setting fields.
        $value = wp_unslash($_POST[$name] ?? $min); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked by caller; sanitized below.
        $value = is_scalar($value) ? absint((string) $value) : $min;
        return max($min, min($max, $value));
    }

    private function enabledSetting(string $name): bool
    {
        // The caller verifies capture_relay_save_settings immediately before
        // reading these dynamic setting fields.
        $value = wp_unslash($_POST[$name] ?? '0'); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked by caller; sanitized below.
        return is_scalar($value) && sanitize_key((string) $value) === '1';
    }
}
