<?php

namespace CaptureRelay;

use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

final class RestController
{
    /** @var array<int, object> */
    private array $authenticatedDevices = [];

    public function registerRoutes(): void
    {
        $public = ['permission_callback' => '__return_true'];
        $deviceProtected = ['permission_callback' => [$this, 'devicePermission']];
        register_rest_route(Config::API_NAMESPACE, '/status', array_merge($public, [
            'methods' => 'GET', 'callback' => [$this, 'status'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/connection-requests', array_merge($public, [
            'methods' => 'POST', 'callback' => [$this, 'createRequest'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/connection-requests/status', array_merge($public, [
            'methods' => 'POST', 'callback' => [$this, 'requestStatus'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/connection-requests/complete', array_merge($public, [
            'methods' => 'POST', 'callback' => [$this, 'completeRequest'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/connection-requests/acknowledge', array_merge($deviceProtected, [
            'methods' => 'POST', 'callback' => [$this, 'acknowledgeRequest'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/connection-requests/cancel', array_merge($public, [
            'methods' => 'POST', 'callback' => [$this, 'cancelRequest'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/configuration', array_merge($deviceProtected, [
            'methods' => 'GET', 'callback' => [$this, 'configuration'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/connection', array_merge($deviceProtected, [
            'methods' => 'GET', 'callback' => [$this, 'connection'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/disconnect', array_merge($deviceProtected, [
            'methods' => 'POST', 'callback' => [$this, 'disconnect'],
        ]));
        register_rest_route(Config::API_NAMESPACE, '/media', array_merge($deviceProtected, [
            'methods' => 'POST', 'callback' => [$this, 'uploadMedia'],
        ]));
    }

    /**
     * Authenticate approved-device routes before their callbacks execute.
     */
    public function devicePermission(WP_REST_Request $request): true|WP_Error
    {
        $route = $request->get_route();
        if (str_ends_with($route, '/configuration') || str_ends_with($route, '/connection')) {
            $bodyHash = Security::emptyBodyHash();
        } elseif (str_ends_with($route, '/media')) {
            $bodyHash = Security::bodyHash($this->uploadMetadata($request->get_params()));
        } else {
            $bodyHash = Security::bodyHash($this->json($request));
        }

        $device = Security::authenticate($request, $bodyHash);
        if (is_wp_error($device)) {
            $limited = RateLimiter::hit(
                'auth-failure-ip:' . Security::hashIp(Security::clientIp()),
                (int) Config::get('media_requests_per_hour_ip', 240)
            );
            return is_wp_error($limited) ? $limited : $device;
        }

        $this->authenticatedDevices[spl_object_id($request)] = $device;
        return true;
    }

    public function status(): WP_REST_Response
    {
        $httpsRequired = !$this->isDevelopmentHost();
        return $this->response([
            'plugin_available' => true,
            'plugin_version' => CAPTURE_RELAY_VERSION,
            'api_version' => 'v1',
            'network_id' => Config::NETWORK_ID,
            'new_requests_enabled' => (bool) Config::get('new_requests_enabled'),
            'https_required' => $httpsRequired,
            'official_app_verification_required' => AppProof::required(),
            'official_app_verification_available' => AppProof::available(),
        ]);
    }

    public function createRequest(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        if (!Config::get('new_requests_enabled')) {
            return $this->error('capture_relay_requests_disabled', __('This website is not accepting new connections.', 'capture-relay'), 403);
        }
        if (!is_ssl() && !$this->isDevelopmentHost()) {
            return $this->error('capture_relay_https_required', __('HTTPS is required to connect a device.', 'capture-relay'), 400);
        }

        $input = $this->json($request);
        $deviceUuid = sanitize_text_field((string) ($input['device_uuid'] ?? ''));
        $deviceName = sanitize_text_field((string) ($input['device_name'] ?? ''));
        $clientNonce = sanitize_text_field((string) ($input['client_nonce'] ?? ''));
        $platform = sanitize_key((string) ($input['platform'] ?? ''));
        if (!preg_match('/^[A-Za-z0-9-]{16,64}$/', $deviceUuid) || $deviceName === '' || mb_strlen($deviceName) > 160) {
            return $this->error('capture_relay_invalid_device', __('Device information is invalid.', 'capture-relay'), 400);
        }
        if (!in_array($platform, ['ios', 'android'], true)) {
            return $this->error('capture_relay_invalid_platform', __('The mobile platform is invalid.', 'capture-relay'), 400);
        }
        if (!preg_match('/^[A-Za-z0-9_-]{16,96}$/', $clientNonce)) {
            return $this->error('capture_relay_invalid_client_nonce', __('The connection nonce is invalid.', 'capture-relay'), 400);
        }

        $clientIp = Security::clientIp();
        $ipHash = Security::hashIp($clientIp);
        $identityHash = Config::identityHash($deviceUuid, $platform);
        foreach ([
            ['connect-global', (int) Config::get('requests_per_hour_global', 60)],
            ['connect-ip:' . $ipHash, (int) Config::get('requests_per_hour_ip', 10)],
            ['connect-device:' . $identityHash, (int) Config::get('requests_per_hour_device', 3)],
        ] as [$bucket, $limit]) {
            $limited = RateLimiter::hit($bucket, $limit);
            if (is_wp_error($limited)) {
                return $limited;
            }
        }

        if (DeviceBlocklist::find($identityHash)) {
            DeviceBlocklist::recordBlockedAttempt($identityHash);
            Log::write('blocked_device_request', 'A permanently blocked device attempted to reconnect.', [], 'warning');
            return $this->error('capture_relay_device_blocked', __('This device has been blocked by this website.', 'capture-relay'), 403);
        }
        $appProof = AppProof::verify($request, $input, $platform);
        if (is_wp_error($appProof)) {
            return $appProof;
        }

        global $wpdb;
        $lockName = 'capture-relay-connect-' . substr(hash('sha256', Config::table('requests')), 0, 32);
        $locked = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 3)', $lockName));
        if ($locked !== 1) {
            return $this->error('capture_relay_connection_busy', __('The website is processing other connection requests. Please try again.', 'capture-relay'), 503);
        }
        $nowMysql = current_time('mysql', true);
        $wpdb->query($wpdb->prepare(
            "UPDATE %i SET status = 'expired', ip_address = '' WHERE status = 'pending' AND expires_at < %s",
            Config::table('requests'),
            $nowMysql
        ));
        $existingPending = $wpdb->get_var($wpdb->prepare(
            "SELECT request_id FROM %i WHERE identity_hash = %s AND status = 'pending' AND expires_at >= %s LIMIT 1",
            Config::table('requests'),
            $identityHash,
            $nowMysql
        ));
        if ($existingPending) {
            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
            return $this->error('capture_relay_request_already_pending', __('This device already has a request awaiting review.', 'capture-relay'), 409);
        }
        $pendingCount = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM %i WHERE status = 'pending' AND expires_at >= %s",
            Config::table('requests'),
            $nowMysql
        ));
        if ($pendingCount >= (int) Config::get('maximum_pending_requests', 25)) {
            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
            return $this->error('capture_relay_pending_limit', __('This website cannot accept another connection request right now.', 'capture-relay'), 429);
        }

        $requestId = Security::randomToken(24);
        $requestSecret = Security::randomToken(32);
        $now = time();
        $expires = $now + ((int) Config::get('request_expiration_hours', 24) * HOUR_IN_SECONDS);
        $recentSameDevice = (int) $wpdb->get_var($wpdb->prepare(
            'SELECT COUNT(*) FROM %i WHERE device_uuid = %s AND created_at >= %s',
            Config::table('requests'),
            $deviceUuid,
            gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS)
        ));
        $created = $wpdb->insert(Config::table('requests'), [
            'request_id' => $requestId,
            'secret_hash' => password_hash($requestSecret, PASSWORD_DEFAULT),
            'device_uuid' => $deviceUuid,
            'device_name' => $deviceName,
            'platform' => $platform,
            'os_version' => substr(sanitize_text_field((string) ($input['os_version'] ?? '')), 0, 64),
            'device_model' => substr(sanitize_text_field((string) ($input['device_model'] ?? '')), 0, 120),
            'app_version' => substr(sanitize_text_field((string) ($input['app_version'] ?? '')), 0, 32),
            'requested_capabilities' => wp_json_encode(array_values(array_intersect((array) ($input['capabilities'] ?? []), ['media:write', 'configuration:read']))),
            'client_nonce' => $clientNonce,
            'status' => 'pending',
            'ip_hash' => $ipHash,
            'ip_address' => substr($clientIp, 0, 45),
            'created_at' => gmdate('Y-m-d H:i:s', $now),
            'expires_at' => gmdate('Y-m-d H:i:s', $expires),
            'identity_hash' => $identityHash,
            'attestation_status' => AppProof::required() ? 'verified' : 'unverified',
        ]);
        $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
        if (!$created) {
            return $this->error('capture_relay_request_create_failed', __('The website could not save this connection request.', 'capture-relay'), 500);
        }

        Log::write('connection_requested', 'A mobile device requested access.', ['request_id' => $requestId]);
        if ($recentSameDevice === 0 || Config::get('repeat_request_emails')) {
            Admin::sendApprovalEmail($deviceName, $platform);
        } else {
            Log::write('approval_email_suppressed', 'A repeated device request was recorded without another approval email.', [
                'request_id' => $requestId,
                'reason' => 'same_device_within_24_hours',
            ]);
        }

        return $this->response([
            'request_id' => $requestId,
            'request_secret' => $requestSecret,
            'expires_at' => gmdate('c', $expires),
            'website_name' => get_bloginfo('name'),
            'website_url' => home_url('/'),
            'status' => 'pending',
        ], 201);
    }

    public function requestStatus(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $limited = $this->limitPublicPairing();
        if (is_wp_error($limited)) {
            return $limited;
        }
        $input = $this->json($request);
        $record = $this->requestRecord((string) ($input['request_id'] ?? ''));
        if (is_wp_error($record)) {
            return $record;
        }
        $proof = Security::verifyPendingProof($record, $input);
        if (is_wp_error($proof)) {
            return $proof;
        }
        $record = $this->expireIfNeeded($record);
        return $this->response([
            'status' => $record->status,
            'completion_available' => $record->status === 'approved' && empty($record->completed_at),
            'expires_at' => gmdate('c', strtotime($record->expires_at . ' UTC')),
        ]);
    }

    public function completeRequest(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $limited = $this->limitPublicPairing();
        if (is_wp_error($limited)) {
            return $limited;
        }
        $input = $this->json($request);
        $record = $this->requestRecord((string) ($input['request_id'] ?? ''));
        if (is_wp_error($record)) {
            return $record;
        }
        $proof = Security::verifyPendingProof($record, $input);
        if (is_wp_error($proof)) {
            return $proof;
        }
        $record = $this->expireIfNeeded($record);
        if ($record->status !== 'approved' || empty($record->device_id)) {
            return $this->error('capture_relay_not_approved', __('This connection request has not been approved.', 'capture-relay'), 403);
        }
        if (!empty($record->completed_at)) {
            return $this->error('capture_relay_completion_used', __('The approved credential has already been delivered.', 'capture-relay'), 410);
        }

        global $wpdb;
        $device = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE device_id = %s LIMIT 1',
            Config::table('devices'),
            $record->device_id
        ));
        if (!$device || $device->status !== 'approved') {
            return $this->error('capture_relay_device_not_approved', __('This device is no longer approved.', 'capture-relay'), 403);
        }

        $envelope = json_decode((string) $record->credential_envelope, true);
        if (!is_array($envelope) || empty($record->envelope_expires_at) || strtotime($record->envelope_expires_at . ' UTC') < time()) {
            try {
                $credential = CredentialVault::open((string) $device->credential_cipher);
                $envelope = $this->pairingEnvelope($credential, (string) $input['request_secret'], $record->request_id);
                unset($credential);
            } catch (\RuntimeException $exception) {
                Log::write('credential_completion_failed', 'The approved credential envelope could not be prepared.', [
                    'request_id' => $record->request_id,
                    'device_id' => $device->device_id,
                    'exception_type' => get_class($exception),
                ], 'error');
                return $this->error('capture_relay_completion_failed', __('The website could not prepare this device credential.', 'capture-relay'), 500);
            }
            $wpdb->update(Config::table('requests'), [
                'credential_envelope' => wp_json_encode($envelope),
                'envelope_expires_at' => gmdate('Y-m-d H:i:s', time() + 15 * MINUTE_IN_SECONDS),
            ], ['request_id' => $record->request_id], ['%s', '%s'], ['%s']);
        }

        return $this->response([
            'website_id' => (string) $device->website_id,
            'device_id' => $device->device_id,
            'credential_envelope' => $envelope,
            'website_name' => get_bloginfo('name'),
            'website_url' => home_url('/'),
            'website_icon' => get_site_icon_url(192),
            'configuration' => $this->configurationPayload(),
            'server_time' => gmdate('c'),
            'acknowledgement_required' => true,
        ]);
    }

    public function acknowledgeRequest(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $input = $this->json($request);
        $device = $this->authenticatedDevice($request);
        if (is_wp_error($device)) {
            return $device;
        }
        $requestId = sanitize_text_field((string) ($input['request_id'] ?? ''));
        global $wpdb;
        $record = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE request_id = %s AND device_id = %s LIMIT 1',
            Config::table('requests'),
            $requestId,
            $device->device_id
        ));
        if (!$record || $record->status !== 'approved') {
            return $this->error('capture_relay_request_not_found', __('The approved request was not found.', 'capture-relay'), 404);
        }
        if (empty($record->completed_at)) {
            $wpdb->update(Config::table('requests'), [
                'secret_hash' => password_hash(Security::randomToken(48), PASSWORD_DEFAULT),
                'credential_envelope' => null,
                'envelope_expires_at' => null,
                'completed_at' => current_time('mysql', true),
            ], ['request_id' => $requestId], ['%s', '%s', '%s', '%s'], ['%s']);
            Log::write('credential_acknowledged', 'An approved device securely stored its credential.', ['request_id' => $requestId, 'device_id' => $device->device_id]);
        }
        return $this->response(['acknowledged' => true]);
    }

    public function cancelRequest(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $limited = $this->limitPublicPairing();
        if (is_wp_error($limited)) {
            return $limited;
        }
        $input = $this->json($request);
        $record = $this->requestRecord((string) ($input['request_id'] ?? ''));
        if (is_wp_error($record)) {
            return $record;
        }
        $proof = Security::verifyPendingProof($record, $input);
        if (is_wp_error($proof)) {
            return $proof;
        }
        if ($record->status !== 'pending') {
            return $this->error('capture_relay_not_pending', __('Only a pending request can be cancelled.', 'capture-relay'), 409);
        }
        global $wpdb;
        $wpdb->update(Config::table('requests'), [
            'status' => 'cancelled',
            'secret_hash' => password_hash(Security::randomToken(48), PASSWORD_DEFAULT),
            'decided_at' => current_time('mysql', true),
            'ip_address' => '',
        ], ['request_id' => $record->request_id], ['%s', '%s', '%s', '%s'], ['%s']);
        Log::write('connection_cancelled', 'A device cancelled its connection request.', ['request_id' => $record->request_id]);
        return $this->response(['status' => 'cancelled']);
    }

    public function configuration(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $device = $this->authenticatedDevice($request);
        return is_wp_error($device) ? $device : $this->response($this->configurationPayload());
    }

    public function connection(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $device = $this->authenticatedDevice($request);
        if (is_wp_error($device)) {
            return $device;
        }
        return $this->response(['active' => true, 'status' => 'approved', 'configuration_version' => $this->configurationVersion(), 'server_time' => gmdate('c')]);
    }

    public function disconnect(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $device = $this->authenticatedDevice($request);
        if (is_wp_error($device)) {
            return $device;
        }
        global $wpdb;
        $wpdb->update(Config::table('devices'), [
            'status' => 'revoked', 'credential_cipher' => CredentialVault::seal(Security::randomToken(48)), 'revoked_at' => current_time('mysql', true),
        ], ['device_id' => $device->device_id], ['%s', '%s', '%s'], ['%s']);
        Log::write('device_disconnected', 'A device disconnected itself.', ['device_id' => $device->device_id]);
        return $this->response(['status' => 'revoked']);
    }

    public function uploadMedia(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $params = $request->get_params();
        $metadata = $this->uploadMetadata($params);
        if (!preg_match('/^[a-f0-9]{64}$/', $metadata['file_sha256'])) {
            return $this->error('capture_relay_invalid_file_hash', __('The image signature is invalid.', 'capture-relay'), 400);
        }
        if (!preg_match('/^[A-Za-z0-9-]{16,64}$/', $metadata['client_upload_id'])) {
            return $this->error('capture_relay_invalid_upload_id', __('The upload identifier is invalid.', 'capture-relay'), 400);
        }
        foreach (['photo_credit' => 160, 'image_title' => 160, 'image_alt_text' => 300, 'app_version' => 32, 'dimensions' => 24, 'capture_timestamp' => 40, 'device_timestamp' => 40] as $key => $maximum) {
            if ($key === 'image_alt_text' && !array_key_exists($key, $metadata)) {
                continue;
            }
            if (mb_strlen($metadata[$key]) > $maximum) {
                return $this->error('capture_relay_invalid_upload_metadata', __('The upload metadata is invalid.', 'capture-relay'), 400);
            }
        }
        $captureTimestamp = $this->normalizeTimestamp($metadata['capture_timestamp']);
        $deviceTimestamp = $this->normalizeTimestamp($metadata['device_timestamp']);
        if ($captureTimestamp === null || $deviceTimestamp === null) {
            return $this->error('capture_relay_invalid_timestamp', __('The upload timestamp is invalid.', 'capture-relay'), 400);
        }
        $metadata['capture_timestamp'] = $captureTimestamp;
        $metadata['device_timestamp'] = $deviceTimestamp;

        $device = $this->authenticatedDevice($request);
        if (is_wp_error($device)) {
            return $device;
        }
        $limited = RateLimiter::hit('upload-device:' . $device->device_id, (int) Config::get('uploads_per_hour_device', 120));
        if (is_wp_error($limited)) {
            return $limited;
        }
        if (!Config::get('uploads_enabled')) {
            return $this->error('capture_relay_uploads_disabled', __('Website is currently not accepting submissions.', 'capture-relay'), 403);
        }

        $files = $request->get_file_params();
        $file = $files['image'] ?? null;
        if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
            return $this->error('capture_relay_image_missing', __('A valid image file is required.', 'capture-relay'), 400);
        }
        if ((int) $file['size'] > (int) Config::get('maximum_upload_bytes')) {
            return $this->error('capture_relay_image_too_large', __('The image exceeds this website’s upload limit.', 'capture-relay'), 413);
        }

        $actualFileHash = hash_file('sha256', $file['tmp_name']);
        if (!hash_equals($actualFileHash, $metadata['file_sha256'])) {
            return $this->error('capture_relay_file_hash_mismatch', __('The uploaded image does not match its signature.', 'capture-relay'), 401);
        }

        $title = trim(sanitize_text_field($metadata['image_title']));
        $altText = trim(sanitize_text_field($metadata['image_alt_text'] ?? ''));
        if ($altText === '') {
            $altText = $title;
        }

        global $wpdb;
        $existing = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE client_upload_id = %s AND device_id = %s LIMIT 1',
            Config::table('uploads'),
            $metadata['client_upload_id'], $device->device_id
        ));
        if ($existing && $existing->status === 'uploaded' && $existing->attachment_id) {
            return $this->response($this->attachmentResponse((int) $existing->attachment_id, true));
        }
        if ($existing && $existing->status === 'processing' && strtotime($existing->created_at . ' UTC') >= time() - (10 * MINUTE_IN_SECONDS)) {
            return $this->error('capture_relay_upload_in_progress', __('This photo is already being processed. It is safe to retry shortly.', 'capture-relay'), 409);
        }

        $checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], ['jpg|jpeg' => 'image/jpeg']);
        if (($checked['type'] ?? '') !== 'image/jpeg') {
            return $this->error('capture_relay_invalid_image_type', __('Only JPEG images are accepted.', 'capture-relay'), 415);
        }
        $dimensions = wp_getimagesize($file['tmp_name']);
        if (!$dimensions || $dimensions[0] <= $dimensions[1]) {
            return $this->error('capture_relay_landscape_required', __('The uploaded image must be horizontal.', 'capture-relay'), 422);
        }
        [$width, $height] = [(int) $dimensions[0], (int) $dimensions[1]];
        if ($width < Config::MIN_IMAGE_DIMENSION || $height < Config::MIN_IMAGE_DIMENSION || $width > Config::MAX_IMAGE_DIMENSION || $height > Config::MAX_IMAGE_DIMENSION) {
            return $this->error('capture_relay_invalid_image_dimensions', __('The uploaded image dimensions are outside the allowed range.', 'capture-relay'), 422);
        }
        // This intentionally is not administrator-configurable. A fixed
        // ceiling prevents decompression-bomb uploads from exhausting PHP memory.
        if (($width * $height) > Config::MAX_IMAGE_PIXELS) {
            return $this->error('capture_relay_image_pixel_limit', __('The uploaded image contains too many pixels.', 'capture-relay'), 422);
        }
        if (!$this->imageFitsMemoryBudget($width, $height)) {
            return $this->error('capture_relay_image_memory_limit', __('The uploaded image is too large for this website to process safely.', 'capture-relay'), 422);
        }
        if (!hash_equals($width . 'x' . $height, strtolower(trim($metadata['dimensions'])))) {
            return $this->error('capture_relay_dimension_mismatch', __('The uploaded image dimensions do not match the signed upload details.', 'capture-relay'), 422);
        }

        $sanitized = $this->sanitizeImage($file['tmp_name']);
        if (is_wp_error($sanitized)) {
            return $sanitized;
        }
        if ((int) $sanitized['size'] > (int) Config::get('maximum_upload_bytes')) {
            wp_delete_file($sanitized['path']);
            return $this->error('capture_relay_image_too_large', __('The optimized image exceeds this website’s upload limit.', 'capture-relay'), 413);
        }

        if (!$existing) {
            $inserted = $wpdb->insert(Config::table('uploads'), [
                'client_upload_id' => $metadata['client_upload_id'], 'device_id' => $device->device_id,
                'image_title' => $title, 'photo_credit' => substr(sanitize_text_field($metadata['photo_credit']), 0, 160),
                'status' => 'processing', 'capture_at' => $this->mysqlTimestamp($metadata['capture_timestamp']),
                'created_at' => current_time('mysql', true),
            ], ['%s', '%s', '%s', '%s', '%s', '%s', '%s']);
            if (!$inserted) {
                $raced = $wpdb->get_row($wpdb->prepare(
                    'SELECT * FROM %i WHERE client_upload_id = %s AND device_id = %s LIMIT 1',
                    Config::table('uploads'),
                    $metadata['client_upload_id'], $device->device_id
                ));
                if ($raced && $raced->status === 'uploaded' && $raced->attachment_id) {
                    wp_delete_file($sanitized['path']);
                    return $this->response($this->attachmentResponse((int) $raced->attachment_id, true));
                }
                wp_delete_file($sanitized['path']);
                return $this->error('capture_relay_upload_in_progress', __('This photo is already being processed. It is safe to retry shortly.', 'capture-relay'), 409);
            }
        } else {
            $wpdb->update(Config::table('uploads'), [
                'status' => 'processing', 'error_code' => '', 'error_message' => null,
                'created_at' => current_time('mysql', true),
            ], ['id' => $existing->id], ['%s', '%s', '%s', '%s'], ['%d']);
        }

        $outputMime = $this->outputMimeType();
        $outputExtension = $outputMime === 'image/webp' ? 'webp' : 'jpg';
        $safeName = sanitize_file_name(pathinfo($file['name'], PATHINFO_FILENAME)) . '-' . gmdate('Ymd-His') . '.' . $outputExtension;
        $sideload = ['name' => $safeName, 'type' => $outputMime, 'tmp_name' => $sanitized['path'], 'error' => 0, 'size' => $sanitized['size']];
        require_once ABSPATH . 'wp-admin/includes/file.php';
        $handled = wp_handle_sideload($sideload, ['test_form' => false, 'mimes' => $outputMime === 'image/webp' ? ['webp' => 'image/webp'] : ['jpg|jpeg' => 'image/jpeg']]);
        if (isset($handled['error'])) {
            wp_delete_file($sanitized['path']);
            return $this->recordUploadError($metadata['client_upload_id'], $device->device_id, 'sideload_failed', (string) $handled['error']);
        }

        $caption = Caption::format((string) $metadata['photo_credit']);
        $attachmentId = wp_insert_attachment([
            'post_mime_type' => $outputMime, 'post_title' => $title, 'post_excerpt' => $caption,
            'post_content' => '', 'post_status' => 'inherit',
        ], $handled['file']);
        if (is_wp_error($attachmentId)) {
            wp_delete_file($handled['file']);
            return $this->recordUploadError($metadata['client_upload_id'], $device->device_id, 'attachment_failed', $attachmentId->get_error_message());
        }

        require_once ABSPATH . 'wp-admin/includes/image.php';
        wp_update_attachment_metadata($attachmentId, wp_generate_attachment_metadata($attachmentId, $handled['file']));
        update_post_meta($attachmentId, '_wp_attachment_image_alt', $altText);
        update_post_meta($attachmentId, '_capture_relay_device_id', $device->device_id);
        update_post_meta($attachmentId, '_capture_relay_client_upload_id', $metadata['client_upload_id']);
        update_post_meta($attachmentId, '_capture_relay_capture_timestamp', $metadata['capture_timestamp']);
        update_post_meta($attachmentId, '_capture_relay_upload_timestamp', gmdate('c'));
        update_post_meta($attachmentId, '_capture_relay_app_version', sanitize_text_field($metadata['app_version']));
        update_post_meta($attachmentId, '_capture_relay_dimensions', sanitize_text_field($metadata['dimensions']));
        $defaultCopyright = trim((string) Config::get('default_copyright'));
        if ($defaultCopyright !== '') {
            update_post_meta($attachmentId, '_capture_relay_copyright', sanitize_text_field($defaultCopyright));
        }
        $wpdb->update(Config::table('uploads'), [
            'attachment_id' => $attachmentId, 'status' => 'uploaded', 'completed_at' => current_time('mysql', true),
        ], ['client_upload_id' => $metadata['client_upload_id'], 'device_id' => $device->device_id], ['%d', '%s', '%s'], ['%s', '%s']);
        $wpdb->update(Config::table('devices'), ['last_upload_at' => current_time('mysql', true)], ['device_id' => $device->device_id], ['%s'], ['%s']);
        Log::write('media_uploaded', 'A mobile image was added to the Media Library.', ['device_id' => $device->device_id, 'attachment_id' => $attachmentId]);
        Admin::sendUploadEmail((int) $attachmentId, $title !== '' ? $title : __('Untitled image', 'capture-relay'), (string) ($device->display_name ?: $device->device_id), (int) $sanitized['size']);
        return $this->response($this->attachmentResponse((int) $attachmentId, false), 201);
    }

    private function configurationPayload(): array
    {
        return [
            'configuration_version' => $this->configurationVersion(),
            'minimum_image_dimension' => Config::MIN_IMAGE_DIMENSION,
            'maximum_image_dimension' => Config::MAX_IMAGE_DIMENSION,
            'required_image_width' => (int) Config::get('required_image_width', 800),
            'required_image_height' => (int) Config::get('required_image_height', 500),
            'maximum_upload_bytes' => (int) Config::get('maximum_upload_bytes'),
            'jpeg_quality' => (int) Config::get('jpeg_quality', 75),
            'output_image_format' => $this->outputImageFormat(),
            'allowed_formats' => array_values((array) Config::get('allowed_formats')),
            'caption_format' => (string) Config::get('caption_format'),
            'separate_alt_text' => true,
            'caption_preview' => Caption::format('Jane Smith'),
            'metadata_policy' => (string) Config::get('metadata_policy'),
            'api_version' => 'v1',
        ];
    }

    private function configurationVersion(): string
    {
        return substr(hash('sha256', wp_json_encode($this->configurationPayloadWithoutVersion())), 0, 16);
    }

    private function configurationPayloadWithoutVersion(): array
    {
        return [Config::MIN_IMAGE_DIMENSION, Config::MAX_IMAGE_DIMENSION, Config::get('required_image_width', 800), Config::get('required_image_height', 500), Config::get('maximum_upload_bytes'), Config::get('jpeg_quality', 75), $this->outputImageFormat(), Config::get('caption_format'), Config::get('metadata_policy'), true];
    }

    private function pairingEnvelope(string $credential, string $requestSecret, string $requestId): array
    {
        $iv = random_bytes(12);
        $tag = '';
        $key = hash_hkdf('sha256', $requestSecret, 32, 'capture-relay-pairing-v1', $requestId);
        $ciphertext = openssl_encrypt($credential, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $requestId, 16);
        if ($ciphertext === false) {
            throw new \RuntimeException('Pairing encryption failed.');
        }
        return [
            'algorithm' => 'A256GCM-HKDF-SHA256', 'iv' => base64_encode($iv),
            'ciphertext' => base64_encode($ciphertext), 'tag' => base64_encode($tag),
            'key_info' => 'capture-relay-pairing-v1', 'aad' => $requestId,
        ];
    }

    private function requestRecord(string $requestId): object
    {
        if (!preg_match('/^[A-Za-z0-9_-]{24,64}$/', $requestId)) {
            return $this->error('capture_relay_request_not_found', __('The connection request was not found.', 'capture-relay'), 404);
        }
        global $wpdb;
        $record = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE request_id = %s LIMIT 1',
            Config::table('requests'),
            $requestId
        ));
        return $record ?: $this->error('capture_relay_request_not_found', __('The connection request was not found.', 'capture-relay'), 404);
    }

    private function expireIfNeeded(object $record): object
    {
        $uncompletedApproval = $record->status === 'approved' && empty($record->completed_at);
        if (($record->status === 'pending' || $uncompletedApproval) && strtotime($record->expires_at . ' UTC') < time()) {
            global $wpdb;
            $wpdb->update(Config::table('requests'), [
                'status' => 'expired', 'ip_address' => '', 'credential_envelope' => null, 'envelope_expires_at' => null,
            ], ['request_id' => $record->request_id], ['%s', '%s', '%s', '%s'], ['%s']);
            if ($uncompletedApproval && !empty($record->device_id)) {
                $wpdb->update(Config::table('devices'), [
                    'status' => 'revoked',
                    'credential_cipher' => CredentialVault::seal(Security::randomToken(48)),
                    'revoked_at' => current_time('mysql', true),
                ], ['device_id' => $record->device_id], ['%s', '%s', '%s'], ['%s']);
            }
            $record->status = 'expired';
            Log::write('connection_expired', 'A pending connection request expired.', ['request_id' => $record->request_id]);
        }
        return $record;
    }

    private function uploadMetadata(array $params): array
    {
        $keys = ['client_upload_id', 'image_title', 'photo_credit', 'capture_timestamp', 'device_timestamp', 'app_version', 'dimensions', 'file_sha256'];
        if (array_key_exists('image_alt_text', $params)) {
            $keys[] = 'image_alt_text';
        }
        $metadata = [];
        foreach ($keys as $key) {
            $value = $params[$key] ?? '';
            $metadata[$key] = is_scalar($value) ? (string) $value : '';
        }
        return $metadata;
    }

    private function limitPublicPairing(): true|WP_Error
    {
        return RateLimiter::hit(
            'pairing-ip:' . Security::hashIp(Security::clientIp()),
            (int) Config::get('pairing_requests_per_hour_ip', 1200)
        );
    }

    private function authenticatedDevice(WP_REST_Request $request): object
    {
        $device = $this->authenticatedDevices[spl_object_id($request)] ?? null;
        if (!is_object($device)) {
            return $this->error('capture_relay_authentication_required', __('Device authentication is required.', 'capture-relay'), 401);
        }
        return $device;
    }

    private function normalizeTimestamp(string $value): ?string
    {
        if (strlen($value) < 20 || strlen($value) > 40) {
            return null;
        }
        if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/', $value)) {
            return null;
        }
        $parts = date_parse($value);
        if (($parts['error_count'] ?? 1) > 0 || ($parts['warning_count'] ?? 1) > 0) {
            return null;
        }
        try {
            $timestamp = (new \DateTimeImmutable($value))->getTimestamp();
        } catch (\Throwable $exception) {
            return null;
        }
        if ($timestamp < -2208988800 || $timestamp > time() + DAY_IN_SECONDS) {
            return null;
        }
        return gmdate('Y-m-d\TH:i:s\Z', $timestamp);
    }

    private function imageFitsMemoryBudget(int $width, int $height): bool
    {
        $memoryLimit = wp_convert_hr_to_bytes((string) ini_get('memory_limit'));
        if ($memoryLimit <= 0) {
            return true;
        }
        // Decoding and re-encoding can hold multiple pixel buffers. Eight bytes
        // per pixel plus a fixed reserve is intentionally conservative.
        $estimatedBytes = ($width * $height * 8) + (16 * MB_IN_BYTES);
        $availableBytes = max(0, $memoryLimit - memory_get_usage(true) - (16 * MB_IN_BYTES));
        return $estimatedBytes <= $availableBytes;
    }

    private function outputImageFormat(): string
    {
        return Config::get('output_image_format', 'jpeg') === 'webp' ? 'webp' : 'jpeg';
    }

    private function outputMimeType(): string
    {
        return $this->outputImageFormat() === 'webp' ? 'image/webp' : 'image/jpeg';
    }

    private function sanitizeImage(string $sourcePath): array|WP_Error
    {
        require_once ABSPATH . 'wp-admin/includes/file.php';
        $outputMime = $this->outputMimeType();
        if (!wp_image_editor_supports(['mime_type' => $outputMime])) {
            return $this->error('capture_relay_output_format_unavailable', __('This website cannot create the selected image format. Choose JPEG in CaptureRelay Settings or enable WebP support on the server.', 'capture-relay'), 422);
        }
        $temporary = wp_tempnam('capture-relay-sanitized.' . ($outputMime === 'image/webp' ? 'webp' : 'jpg'));
        if (!$temporary) {
            return $this->error('capture_relay_image_processing_failed', __('The website could not prepare this image.', 'capture-relay'), 500);
        }
        $quality = (int) Config::get('jpeg_quality', 75);
        $editor = wp_get_image_editor($sourcePath);
        if (is_wp_error($editor)) {
            wp_delete_file($temporary);
            return $this->error('capture_relay_image_processing_failed', __('The website could not safely rewrite this image.', 'capture-relay'), 422);
        }
        $qualitySet = $editor->set_quality($quality);
        if (is_wp_error($qualitySet)) {
            wp_delete_file($temporary);
            return $this->error('capture_relay_image_processing_failed', __('The website could not safely rewrite this image.', 'capture-relay'), 422);
        }
        $rotated = $editor->maybe_exif_rotate();
        if (is_wp_error($rotated)) {
            wp_delete_file($temporary);
            return $this->error('capture_relay_image_processing_failed', __('The website could not safely orient this image.', 'capture-relay'), 422);
        }
        $saved = $editor->save($temporary, $outputMime);
        if (is_wp_error($saved) || empty($saved['path']) || !is_file($saved['path']) || filesize($saved['path']) < 1) {
            wp_delete_file($temporary);
            return $this->error('capture_relay_image_processing_failed', __('The website could not safely rewrite this image.', 'capture-relay'), 422);
        }
        $savedPath = (string) $saved['path'];
        if ($savedPath !== $temporary) {
            wp_delete_file($temporary);
        }
        return ['path' => $savedPath, 'size' => (int) filesize($savedPath)];
    }

    private function recordUploadError(string $uploadId, string $deviceId, string $code, string $message): WP_Error
    {
        global $wpdb;
        $wpdb->update(Config::table('uploads'), ['status' => 'failed', 'error_code' => $code, 'error_message' => sanitize_text_field($message)], ['client_upload_id' => $uploadId, 'device_id' => $deviceId], ['%s', '%s', '%s'], ['%s', '%s']);
        Log::write('media_upload_failed', 'A mobile image upload failed.', ['device_id' => $deviceId, 'error_code' => $code], 'error');
        return $this->error('capture_relay_' . $code, __('The website could not save this photo. It is safe to retry.', 'capture-relay'), 500);
    }

    private function attachmentResponse(int $attachmentId, bool $idempotent): array
    {
        return ['attachment_id' => $attachmentId, 'media_url' => wp_get_attachment_url($attachmentId), 'idempotent' => $idempotent, 'status' => 'uploaded'];
    }

    private function mysqlTimestamp(string $value): ?string
    {
        $timestamp = strtotime($value);
        return $timestamp ? gmdate('Y-m-d H:i:s', $timestamp) : null;
    }

    private function json(WP_REST_Request $request): array
    {
        $body = $request->get_json_params();
        return is_array($body) ? $body : [];
    }

    private function isDevelopmentHost(): bool
    {
        $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
        return in_array($host, ['localhost', '127.0.0.1', '::1'], true) || str_ends_with($host, '.test') || (bool) apply_filters('capture_relay_allow_insecure_development', false);
    }

    private function response(array $data, int $status = 200): WP_REST_Response
    {
        $response = new WP_REST_Response($data, $status);
        $response->header('Cache-Control', 'no-store, private');
        $response->header('X-Content-Type-Options', 'nosniff');
        return $response;
    }

    private function error(string $code, string $message, int $status): WP_Error
    {
        return new WP_Error($code, $message, ['status' => $status]);
    }
}
