<?php
namespace EncypherProvenance;

use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

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

require_once __DIR__ . '/class-encypher-logger.php';
require_once __DIR__ . '/class-encypher-signing-status.php';
require_once __DIR__ . '/class-encypher-vip-media-delivery.php';
require_once __DIR__ . '/class-encypher-rsl-export.php';
require_once __DIR__ . '/class-encypher-editorial-provenance.php';
require_once __DIR__ . '/class-encypher-editorial-metadata.php';
require_once __DIR__ . '/class-encypher-reader-verdict.php';
require_once __DIR__ . '/class-encypher-environment-guard.php';
require_once __DIR__ . '/class-encypher-capabilities.php';

/**
 * Registers custom REST endpoints for signing and verification actions.
 */
class Rest
{
    /**
     * Version of the Encypher Terms of Service the activation flow presents.
     * Bump when https://encypher.com/terms changes materially so re-consent
     * can be required; the accepted version is persisted alongside the
     * acceptance timestamp in the encypher_tos_accepted option.
     */
    public const TOS_VERSION = '2.0';
    private const WRITE_MUTATED = 'mutated';
    private const WRITE_OWNED_NO_CHANGE = 'owned_no_change';
    private const WRITE_SUPERSEDED = 'superseded';
    private const WRITE_DB_ERROR = 'db_error';
    private const HASH_RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024;
    private const ATTACHMENT_COMMIT_RENEW_SECONDS = 60;
    private const HASH_CAPABILITIES_TTL = 300;
    private const BMFF_EXCLUSIONS = ['/uuid', '/ftyp', '/mfra', '/free', '/skip'];
    /**
     * Attachment metadata whose exact pre-rich-run state is restored when the
     * parent generation does not commit.
     */
    private const RICH_ATTACHMENT_ROLLBACK_META_KEYS = [
        '_wp_attachment_metadata',
        '_encypher_c2pa_instance_id',
        '_encypher_image_manifest_url',
        '_encypher_image_record_id',
        '_encypher_signed_hash',
        '_encypher_signed_at',
        '_encypher_signed_file_hash',
        '_encypher_signed_generation',
        '_encypher_signed_as_part_of',
        '_encypher_signed_action',
        '_encypher_signed_rights_evidence',
        '_encypher_rights_evidence_id',
        '_encypher_rights_resolution_url',
        '_encypher_source_has_c2pa',
        '_encypher_source_c2pa_valid',
        '_encypher_provenance_status',
        '_encypher_last_sign_error',
        '_encypher_public_delivery_state',
    ];
    /** @var array<int,array{generation:string,content:string,candidate_content:string,held_content:string}> */
    private array $staged_generation_contents = [];
    /** @var array<int,array<string,mixed>> */
    private array $staged_generation_snapshots = [];

    private bool $is_signing = false;
    private ?HtmlParser $parser = null;
    /** @var array<int,string> */
    private array $deferred_generation_tokens = [];
    /** @var array<int,string> Terminal leases released by scope unwinding. */
    private array $unwound_text_projection_terminals = [];
    /** @var array<int,string> Exact terminal leases held by this response projector. */
    private array $text_projection_terminals = [];
    /** @var array<int,string> Advisory mutex names held by this instance. */
    private array $text_projection_mutexes = [];
    /** @var array<string,bool> Process-local guard against reentrant projection callbacks. */
    private static array $held_text_projection_mutexes = [];
    /**
     * Per-request prepared-signing capability memo, keyed by (api base + key).
     *
     * @var array<string,array<string,mixed>|null>
     */
    private array $hash_capabilities = [];
    /** @var bool True while a batched hash-signing wave is collecting requests. */
    private bool $hash_wave_active = false;

    private function is_autosave_or_revision(int $post_id): bool
    {
        return (function_exists('wp_is_post_autosave') && (bool) wp_is_post_autosave($post_id))
            || (function_exists('wp_is_post_revision') && (bool) wp_is_post_revision($post_id));
    }

    public function __construct()
    {
        $this->parser = new HtmlParser();
    }

    private function get_parser(): HtmlParser
    {
        if ($this->parser === null) {
            $this->parser = new HtmlParser();
        }
        return $this->parser;
    }


    private function debug_log(string $message): void
    {
        Logger::log('Encypher: ' . $message);
    }

    public function register_hooks(): void
    {
        add_action('rest_api_init', [$this, 'register_routes']);
        add_action('encypher_refresh_account_entitlements', [$this, 'refresh_account_entitlements']);
        $this->schedule_entitlement_refresh();
        add_action('init', [$this, 'register_post_meta']);
        $settings = get_option('encypher_provenance_settings', []);
        $post_types = isset($settings['post_types']) && is_array($settings['post_types'])
            ? $settings['post_types']
            : ['post', 'page'];
        $sanitize_post_type = function ($value): string {
            return function_exists('sanitize_key')
                ? sanitize_key((string) $value)
                : strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', (string) $value));
        };
        foreach (array_unique(array_map($sanitize_post_type, $post_types)) as $post_type) {
            if ('' !== $post_type) {
                add_action('rest_after_insert_' . $post_type, [$this, 'delete_empty_editorial_rest_meta'], 10, 3);
            }
        }
        add_action('save_post', [$this, 'mark_post_needs_verification'], 20, 3);
        add_action('transition_post_status', [$this, 'auto_sign_on_publish'], 10, 3);
        add_action('save_post', [$this, 'auto_sign_on_update'], 30, 3); // Higher priority to run after mark_post_needs_verification
        add_action('admin_notices', [$this, 'render_sign_error_notice']);
        add_action('wp_ajax_encypher_dismiss_sign_error', [$this, 'ajax_dismiss_sign_error']);
        add_action('send_headers', [$this, 'inject_image_provenance_header']);
        add_filter('wp_calculate_image_srcset', [$this, 'disable_srcset_for_signed_images'], 10, 5);
        add_filter('wp_get_attachment_image_attributes', [$this, 'strip_srcset_attrs_for_signed_images'], 10, 3);
        add_filter('image_downsize', [$this, 'force_fullsize_for_signed_images'], 10, 3);
        add_action('encypher_deferred_sign', [$this, 'handle_deferred_sign'], 10, 4);
        foreach (['added_post_meta', 'updated_post_meta', 'deleted_post_meta'] as $meta_hook) {
            add_action($meta_hook, [$this, 'on_editorial_metadata_mutated'], 10, 4);
        }
    }

    /**
     * Register post meta fields for the block editor (Gutenberg).
     *
     * Makes _encypher_rights readable and writable via the REST API so the
     * editor sidebar can persist document-level rights selections.
     */
    public function register_post_meta(): void
    {
        register_post_meta('', '_encypher_rights', [
            'show_in_rest'  => true,
            'single'        => true,
            'type'          => 'string',
            'sanitize_callback' => [self::class, 'sanitize_rights_selection'],
            'auth_callback' => function () {
                return current_user_can('edit_posts');
            },
        ]);

        // Signed evidence is stored as one server-owned JSON pair. It is not
        // exposed through REST and cannot be written by an editor client.
        register_post_meta('', '_encypher_signed_rights_evidence', [
            'show_in_rest'  => false,
            'single'        => true,
            'type'          => 'string',
            'auth_callback' => static function (): bool {
                return false;
            },
        ]);

        // Expose source-provenance + Encypher-signed status meta on attachments
        // to the block editor (read) so image-block inspector hints can render.
        // These fields are server-owned: REST clients may read them but may not
        // forge provenance state through attachment meta writes.
        $attachment_read_meta = [
            '_encypher_source_has_c2pa'      => 'boolean',
            '_encypher_source_c2pa_signer'   => 'string',
            '_encypher_source_c2pa_format'   => 'string',
            '_encypher_source_c2pa_summary'  => 'string',
            '_encypher_source_c2pa_rights'   => 'string',
            '_encypher_provenance_status'    => 'string',
        ];
        foreach ($attachment_read_meta as $meta_key => $meta_type) {
            register_post_meta('attachment', $meta_key, [
                'show_in_rest'  => true,
                'single'        => true,
                'type'          => $meta_type,
                'auth_callback' => static function ($allowed = false, $meta_key = '', $object_id = 0, $user_id = 0, $cap = ''): bool {
                    $cap = is_string($cap) ? $cap : '';
                    if (0 === strpos($cap, 'read_')) {
                        return current_user_can('edit_posts') || current_user_can('edit_post', (int) $object_id);
                    }
                    return false;
                },
            ]);
        }
        foreach (EditorialProvenance::META_FIELDS as $meta_key => $definition) {
            $schema = ['type' => 'string'];
            if ((int) $definition['limit'] > 0) {
                $schema['maxLength'] = (int) $definition['limit'];
            }
            if ('_encypher_digital_source_type' === $meta_key) {
                $schema['enum'] = array_merge([''], EditorialProvenance::DIGITAL_SOURCE_TYPES);
            }
            register_post_meta('', $meta_key, [
                'show_in_rest' => ['schema' => $schema],
                'single' => true,
                'type' => 'string',
                'default' => '',
                'sanitize_callback' => static function ($value) use ($meta_key): string {
                    return EditorialProvenance::sanitize($meta_key, $value);
                },
                'auth_callback' => static function ($allowed = false, $meta_key_arg = '', $object_id = 0): bool {
                    $object_id = (int) $object_id;
                    return $object_id > 0 && current_user_can('edit_post', $object_id);
                },
            ]);
        }
        EditorialMetadata::register_post_meta();
    }


    /**
     * Delete explicitly empty or rejected editorial values after core applies
     * the REST meta payload. Fields absent from the request remain untouched.
     *
     * @param mixed $post
     */
    public function delete_empty_editorial_rest_meta($post, WP_REST_Request $request, bool $creating): void
    {
        $post_id = isset($post->ID) ? (int) $post->ID : 0;
        if ($post_id < 1 || $this->is_autosave_or_revision($post_id)) {
            return;
        }
        $meta = $request->get_param('meta');
        if (! is_array($meta)) {
            return;
        }
        foreach (EditorialProvenance::META_FIELDS as $meta_key => $definition) {
            if (array_key_exists($meta_key, $meta) && '' === EditorialProvenance::sanitize($meta_key, $meta[$meta_key])) {
                delete_post_meta($post_id, $meta_key);
            }
        }
    }

    /**
     * Gutenberg can persist REST meta after save_post. Invalidate an existing
     * signature at the actual meta mutation boundary.
     *
     * @param int|string $meta_id
     * @param int|string $post_id
     * @param mixed $meta_value
     */
    public function on_editorial_metadata_mutated($meta_id, $post_id, string $meta_key = '', $meta_value = null): void
    {
        $tracked = array_merge(
            [EditorialMetadata::META_KEY],
            array_keys(EditorialProvenance::META_FIELDS)
        );
        if (! in_array($meta_key, $tracked, true) || $this->is_signing) {
            return;
        }
        $post_id = (int) $post_id;
        if ($post_id <= 0 || $this->is_autosave_or_revision($post_id)) {
            return;
        }
        if (! get_post_meta($post_id, '_encypher_marked', true)) {
            return;
        }
        update_post_meta($post_id, '_encypher_provenance_status', 'modified');
        delete_post_meta($post_id, '_encypher_provenance_verification');
    }
    /**
     * Keep editor-writable rights meta limited to selection input. Signed
     * evidence returned by Enterprise signing can only enter the trusted meta
     * key through persist_post_rights_evidence().
     *
     * @param mixed $value Candidate meta value.
     */
    public static function sanitize_rights_selection($value): string
    {
        if (! is_string($value)) {
            return '';
        }

        $selection = json_decode($value, true);
        if (! is_array($selection)) {
            return '';
        }
        $strip_evidence = static function (array $data) use (&$strip_evidence): array {
            unset($data['rights_evidence_id'], $data['rights_resolution_url']);
            foreach ($data as $key => $item) {
                if (is_array($item)) {
                    $data[$key] = $strip_evidence($item);
                }
            }
            return $data;
        };
        $selection = $strip_evidence($selection);
        return function_exists('wp_json_encode')
            ? wp_json_encode($selection)
            : json_encode($selection); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress always provides wp_json_encode().
    }

    /**
     * Disable srcset for C2PA-signed images.
     *
     * WordPress generates multiple thumbnail sizes and serves them via the
     * srcset attribute. PHP image resizing strips JUMBF/C2PA metadata, so
     * thumbnails cannot be verified. For signed attachments, return false to
     * force the browser to load only the full-size original (which carries
     * the C2PA manifest).
     *
     * @param array  $sources       Array of image sources for srcset.
     * @param array  $size_array    Width and height of the image.
     * @param string $image_src     The 'src' of the image.
     * @param array  $image_meta    The image metadata from wp_get_attachment_metadata().
     * @param int    $attachment_id The image attachment ID.
     * @return array|false          Empty array to disable srcset, or original sources.
     */
    public function disable_srcset_for_signed_images($sources, $size_array, $image_src, $image_meta, $attachment_id)
    {
        if (VipMediaDelivery::carries_provenance((int) $attachment_id)) {
            return false;
        }
        return $sources;
    }

    /**
     * Strip srcset/sizes HTML attributes for C2PA-signed image attachments.
     *
     * Complements disable_srcset_for_signed_images() by removing any srcset
     * or sizes attributes that WordPress may have already rendered into the
     * <img> tag, ensuring the browser loads only the full-size signed original.
     *
     * @param array   $attr       Image tag attributes.
     * @param WP_Post $attachment The attachment post object.
     * @param string  $size       Requested image size.
     * @return array              Modified attributes.
     */
    public function strip_srcset_attrs_for_signed_images($attr, $attachment, $size)
    {
        return VipMediaDelivery::filter_image_attributes($attr, $attachment);
    }

    /**
     * Force full-size image URL for C2PA-signed attachments.
     *
     * When WordPress renders an <img> for a specific size (e.g., "large",
     * "post-thumbnail"), image_downsize() returns the thumbnail URL. Since
     * thumbnails are deleted for signed attachments, return the full-size
     * URL and dimensions so WordPress never tries to reference a missing
     * thumbnail file.
     *
     * @param bool|array $out         False or array(url, width, height, is_intermediate).
     * @param int        $id          Attachment ID.
     * @param string     $size        Requested image size name.
     * @return bool|array             Full-size data for signed images, or passthrough.
     */
    public function force_fullsize_for_signed_images($out, $id, $size)
    {
        if (! VipMediaDelivery::carries_provenance((int) $id)) {
            return $out;
        }
        $url = VipMediaDelivery::signed_url((int) $id);
        if (! $url) {
            return $out;
        }
        $canonical_url = wp_get_attachment_url($id);
        if ('full' === $size && $url === $canonical_url) {
            return $out;
        }
        $meta = wp_get_attachment_metadata($id);
        $width = $meta['width'] ?? 0;
        $height = $meta['height'] ?? 0;
        return [$url, $width, $height, false];
    }

    public function handle_public_extract_request(WP_REST_Request $request)
    {
        $text = $request->get_param('text');
        if (! is_string($text) || '' === trim($text)) {
            return new WP_Error('invalid_text', __('A text payload containing the embedded manifesto is required.', 'encypher-provenance'), ['status' => 400]);
        }

        // Enforce a hard limit to prevent abuse (default 200k chars)
        $max_length = apply_filters('encypher_public_extract_max_length', 200000);
        if ($max_length && strlen($text) > $max_length) {
            return new WP_Error(
                'text_too_large',
                sprintf(
                    /* translators: %d: max characters */
                    __('Text exceeds the maximum length of %d characters.', 'encypher-provenance'),
                    $max_length
                ),
                ['status' => 413]
            );
        }

        $payload = [
            'text' => $text,
        ];

        // The current Enterprise API serves public text verification at
        // /public/verify only (the legacy /verify alias was removed upstream);
        // production serves both today, so this path works everywhere.
        $response = $this->call_backend('/public/verify', $payload, false, 10); // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- rate-limited public REST XHR triggered by an explicit /extract call, never page-render blocking; bounded by the per-IP limiter.
        if (is_wp_error($response)) {
            return $response;
        }

        return new WP_REST_Response($response);
    }

    public function register_routes(): void
    {
        register_rest_route('encypher-provenance/v1', '/sign', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'can_edit_post'],
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
                'metadata' => [
                    'required' => false,
                    'type' => 'object',
                ],
            ],
            'callback' => [$this, 'handle_sign_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/status', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'can_edit_post'],
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_status_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/media-status', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'can_edit_attachment'],
            'args' => [
                'attachment_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_media_status_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/media-provenance', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'can_edit_attachment'],
            'args' => [
                'attachment_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_media_provenance_request'],
        ]);
        register_rest_route('encypher-provenance/v1', '/media-purge', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'can_edit_attachment'],
            'args' => [
                'attachment_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_media_purge_request'],
        ]);


        register_rest_route('encypher-provenance/v1', '/retry', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'can_edit_post'],
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_retry_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/run-pending', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'can_run_pending_sign'],
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_run_pending_sign_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/verify', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'rate_limit_verify'], // Public endpoint for frontend verification, rate-limited per IP
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_verify_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/verify-media', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'rate_limit_verify_media'],
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
                'attachment_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_verify_media_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/provenance', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'rate_limit_provenance'], // Public endpoint, rate-limited per IP (can trigger a live outbound verify)
            'args' => [
                'post_id' => [
                    'required' => false,
                    'type' => 'integer',
                ],
                'instance_id' => [
                    'required' => false,
                    'type' => 'string',
                ],
            ],
            'callback' => [$this, 'handle_provenance_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/wordpress-ai-status', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'can_edit_post'],
            'args' => [
                'post_id' => [
                    'required' => true,
                    'type' => 'integer',
                ],
            ],
            'callback' => [$this, 'handle_wordpress_ai_status_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/extract', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => [$this, 'rate_limit_extract'], // Public endpoint, rate-limited per IP
            'args' => [
                'text' => [
                    'required' => true,
                    'type' => 'string',
                ],
            ],
            'callback' => [$this, 'handle_public_extract_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/test-connection', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function() {
                return current_user_can('manage_options');
            },
            'args' => [
                'api_base_url' => [
                    'type' => 'string',
                    'required' => false,
                    'sanitize_callback' => 'sanitize_text_field',
                ],
                'api_key' => [
                    'type' => 'string',
                    'required' => false,
                    'sanitize_callback' => 'sanitize_text_field',
                ],
            ],
            'callback' => [$this, 'handle_test_connection_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/quick-connect', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'args' => [
                'api_base_url' => [
                    'type' => 'string',
                    'required' => true,
                    'sanitize_callback' => 'sanitize_text_field',
                ],
                'api_key' => [
                    'type' => 'string',
                    'required' => true,
                    'sanitize_callback' => 'sanitize_text_field',
                ],
            ],
            'callback' => [$this, 'handle_quick_connect_request'],
        ]);


        register_rest_route('encypher-provenance/v1', '/claim', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'args' => [
                'email' => [
                    'type' => 'string',
                    'required' => true,
                    'sanitize_callback' => 'sanitize_email',
                ],
            ],
            'callback' => [$this, 'handle_claim_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/oauth/poll', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_connect_poll_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/oauth/start', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_oauth_start_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/oauth/cancel', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_oauth_cancel_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/account/refresh', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_account_refresh'],
        ]);

        register_rest_route('encypher-provenance/v1', '/org-rights', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'can_edit_post'],
            'callback' => [$this, 'handle_org_rights_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/license-catalog', [
            'methods' => WP_REST_Server::READABLE,
            'permission_callback' => [$this, 'can_edit_post'],
            'callback' => [$this, 'handle_license_catalog_request'],
        ]);

        register_rest_route('encypher-provenance/v1', '/media-scan/start', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_media_scan_start'],
        ]);

        register_rest_route('encypher-provenance/v1', '/queue/run', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_queue_run'],
        ]);

        register_rest_route('encypher-provenance/v1', '/deactivation-feedback', [
            'methods' => WP_REST_Server::CREATABLE,
            'permission_callback' => function () {
                return current_user_can('manage_options');
            },
            'callback' => [$this, 'handle_deactivation_feedback'],
        ]);
    }

    /**
     * Start a retroactive media-library provenance scan (manage_options).
     * Local detection only; the cursor-batched engine runs on WP-Cron.
     */
    public function handle_media_scan_start(WP_REST_Request $request)
    {
        if (! current_user_can('manage_options')) {
            return new WP_Error('rest_forbidden', __('You are not allowed to run a media scan.', 'encypher-provenance'), ['status' => 403]);
        }
        if (! class_exists(MediaScan::class)) {
            return new WP_Error('encypher_scan_unavailable', __('Media scan is unavailable.', 'encypher-provenance'), ['status' => 500]);
        }
        $state = MediaScan::start();
        return new WP_REST_Response(['ok' => true, 'state' => $state], 200);
    }

    /**
     * Synchronously drain a BOUNDED amount of due background work so an admin
     * can unstick a quiet-site queue when WP-Cron is not firing (manage_options
     * only). Never unbounded: at most one deferred-sign batch of 5 due items
     * plus one media-scan batch. Returns the counts processed.
     */
    public function handle_queue_run(WP_REST_Request $request)
    {
        if (! current_user_can('manage_options')) {
            return new WP_Error('rest_forbidden', __('You are not allowed to process the queue.', 'encypher-provenance'), ['status' => 403]);
        }

        $deferred_processed = $this->run_due_deferred_sign_batch(5);
        $media_scan_ran = $this->run_media_scan_batch();

        return new WP_REST_Response([
            'ok'                 => true,
            'deferred_processed' => $deferred_processed,
            'media_scan_ran'     => $media_scan_ran,
        ], 200);
    }

    /**
     * Run up to $max due deferred-sign events synchronously, oldest first,
     * unscheduling each before invoking the handler so a later cron pass never
     * double-signs. Returns the number processed. Bounded by $max.
     */
    private function run_due_deferred_sign_batch(int $max): int
    {
        if ($max <= 0 || ! function_exists('_get_cron_array')) {
            return 0;
        }
        $cron = _get_cron_array();
        if (! is_array($cron)) {
            return 0;
        }

        $now = time();
        $due = [];
        foreach ($cron as $timestamp => $hooks) {
            if ((int) $timestamp > $now) {
                continue; // not due yet
            }
            if (! is_array($hooks) || ! isset($hooks['encypher_deferred_sign']) || ! is_array($hooks['encypher_deferred_sign'])) {
                continue;
            }
            foreach ($hooks['encypher_deferred_sign'] as $event) {
                $due[] = [
                    'ts'   => (int) $timestamp,
                    'args' => (is_array($event) && isset($event['args']) && is_array($event['args'])) ? $event['args'] : [],
                ];
            }
        }
        if ([] === $due) {
            return 0;
        }
        usort($due, static fn(array $a, array $b): int => $a['ts'] <=> $b['ts']);
        $due = array_slice($due, 0, $max);

        $processed = 0;
        foreach ($due as $item) {
            // Unschedule this exact event first so a concurrent cron pass cannot
            // run it again.
            if (function_exists('wp_unschedule_event')) {
                wp_unschedule_event($item['ts'], 'encypher_deferred_sign', $item['args']);
            }
            $args = array_values($item['args']);
            $post_id = isset($args[0]) ? (int) $args[0] : 0;
            if ($post_id <= 0) {
                continue;
            }
            $is_update = isset($args[1]) ? (bool) $args[1] : false;
            $acting    = isset($args[2]) ? (int) $args[2] : 0;
            $kind      = isset($args[3]) ? (string) $args[3] : 'post';
            $this->handle_deferred_sign($post_id, $is_update, $acting, $kind);
            $processed++;
        }
        return $processed;
    }

    /**
     * Run exactly one media-scan batch when a scan is in progress. Bounded by
     * MediaScan::BATCH_SIZE; a no-op otherwise. Returns whether a batch ran.
     */
    private function run_media_scan_batch(): bool
    {
        if (! class_exists(MediaScan::class)) {
            return false;
        }
        $state = MediaScan::get_state();
        if ('running' !== ($state['status'] ?? '')) {
            return false;
        }
        (new MediaScan())->run_batch();
        return true;
    }

    /**
     * Recognized one-click deactivation-survey reasons. Anything else is
     * dropped so the relayed payload can never carry arbitrary strings.
     */
    private const DEACTIVATION_REASONS = [
        'connection_problems',
        'too_complex',
        'did_not_need',
        'something_broke',
    ];

    /**
     * Receive the deactivation exit-survey answer (manage_options + nonce) and
     * relay it best-effort to the Encypher API. This route exists only to keep
     * the site's API key server-side; it MUST NOT block or fail the
     * deactivation the admin just requested. It therefore always returns 200
     * regardless of whether the relay succeeded, timed out, or was skipped.
     */
    public function handle_deactivation_feedback(WP_REST_Request $request)
    {
        if (! current_user_can('manage_options')) {
            return new WP_Error('rest_forbidden', __('You are not allowed to submit deactivation feedback.', 'encypher-provenance'), ['status' => 403]);
        }

        $reason = sanitize_key((string) $request->get_param('reason'));
        if (! in_array($reason, self::DEACTIVATION_REASONS, true)) {
            $reason = '';
        }
        $comment = sanitize_textarea_field((string) $request->get_param('comment'));
        if (strlen($comment) > 1000) {
            $comment = substr($comment, 0, 1000);
        }

        // Best-effort relay; every failure mode is swallowed here so a slow or
        // unreachable API never turns into a hard error the browser could treat
        // as a reason to keep the plugin active.
        try {
            $settings = get_option('encypher_provenance_settings', []);
            if (! is_array($settings)) {
                $settings = [];
            }
            $api_base_url = isset($settings['api_base_url']) ? (string) $settings['api_base_url'] : '';
            $api_key      = isset($settings['api_key']) ? (string) $settings['api_key'] : '';
            $this->relay_deactivation_feedback($api_base_url, $api_key, [
                'install_id' => $this->ensure_local_install_id($settings),
                'reason'     => $reason,
                'comment'    => $comment,
            ]);
        } catch (\Throwable $e) {
            $this->debug_log('Deactivation feedback relay error (ignored): ' . $e->getMessage());
        }

        return new WP_REST_Response(['ok' => true], 200);
    }

    /**
     * Fire-and-forget relay of a deactivation-survey answer to the status-sync
     * endpoint as event "deactivation_feedback". Non-blocking with a hard 3s
     * timeout and no response handling: nothing here waits on or reacts to the
     * network, so the deactivation is never delayed.
     *
     * @param array{install_id:string,reason:string,comment:string} $payload
     */
    private function relay_deactivation_feedback(string $api_base_url, string $api_key, array $payload): void
    {
        $base = self::normalize_api_base_url($api_base_url);
        if ('' === $base || '' === trim($api_key)) {
            return;
        }

        $body = array_merge($payload, ['event' => 'deactivation_feedback']);

        $this->guarded_remote_post($base . '/integrations/wordpress/status-sync', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- best-effort deactivation feedback; bounded 3s and non-blocking, failures ignored.
            'timeout'  => 3,
            'blocking' => false,
            'headers'  => [
                'Accept'        => 'application/json',
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
            'body' => wp_json_encode($body),
        ]);
    }

    public function can_edit_post(WP_REST_Request $request): bool
    {
        $post_id = (int) $request['post_id'];
        if (! $post_id) {
            return current_user_can('edit_posts');
        }

        return current_user_can('edit_post', $post_id);
    }

    public function can_run_pending_sign(WP_REST_Request $request): bool
    {
        $post_id = (int) $request->get_param('post_id');
        return $post_id > 0 && current_user_can('edit_post', $post_id);
    }

    public function can_edit_attachment(WP_REST_Request $request): bool
    {
        $attachment_id = (int) $request->get_param('attachment_id');
        return $attachment_id > 0 && current_user_can('edit_post', $attachment_id);
    }


    /**
     * Proxy the server license catalog to the block editor, cached in a transient.
     *
     * Thin-client: the license/rights templates are the server's single source of
     * truth. This caches GET /api/v1/rights/license-catalog for 15 minutes, keyed
     * by (api_base_url + api key) so switching accounts never serves another org's
     * catalog, and keeps a separate "last good" copy so a transient backend outage
     * degrades to a stale (but correct-org) catalog instead of an empty one. The
     * API ETag is stored and replayed as If-None-Match so a refresh inside the
     * window is a cheap 304.
     */
    public function handle_license_catalog_request(WP_REST_Request $request)
    {
        $settings = get_option('encypher_provenance_settings', []);
        $base_url = isset($settings['api_base_url']) ? rtrim((string) $settings['api_base_url'], '/') : '';
        $api_key = isset($settings['api_key']) ? trim((string) $settings['api_key']) : '';

        $scope = hash('sha256', $base_url . '|' . $api_key);
        $fresh_key = 'encypher_license_catalog_' . $scope;
        $lastgood_key = 'encypher_license_catalog_lg_' . $scope;

        // Fresh cache hit: cheap path inside the TTL window.
        $cached = get_transient($fresh_key);
        if (is_array($cached) && isset($cached['templates'])) {
            $cached['stale'] = false;
            $cached['available'] = true;
            return new WP_REST_Response($cached);
        }

        $lastgood = get_transient($lastgood_key);
        $etag = (is_array($lastgood) && !empty($lastgood['etag'])) ? (string) $lastgood['etag'] : '';
        $extra_headers = $etag !== '' ? ['If-None-Match' => $etag] : [];
        // Relative path: api_base_url already ends in /api/v1 (matches call_backend('/sign'), etc.).
        $response = $this->call_backend_get('/rights/license-catalog', true, 15, $extra_headers);

        $may_use_stale = is_wp_error($response);
        if (! is_wp_error($response)) {
            $code = (int) ($response['status'] ?? 0);
            if ($code === 304 && is_array($lastgood) && isset($lastgood['templates'])) {
                $payload = ['templates' => $lastgood['templates'], 'etag' => $lastgood['etag'] ?? '', 'stale' => false, 'available' => true];
                set_transient($fresh_key, $payload, 15 * MINUTE_IN_SECONDS);
                return new WP_REST_Response($payload);
            }
            $body = $response['body'] ?? null;
            if ($code === 200 && is_array($body) && isset($body['templates']) && is_array($body['templates'])) {
                $payload = [
                    'templates' => $body['templates'],
                    'etag' => $body['etag'] ?? ($response['etag'] ?? ''),
                    'stale' => false,
                    'available' => true,
                ];
                set_transient($fresh_key, $payload, 15 * MINUTE_IN_SECONDS);
                // Last-good copy lives longer so an outage degrades to stale, not empty.
                set_transient($lastgood_key, $payload, DAY_IN_SECONDS);
                return new WP_REST_Response($payload);
            }
            // Stale data is an outage fallback, never an authentication or
            // malformed-response fallback. A 401/403 must not conceal a key
            // boundary failure, and an invalid 200 must not poison the cache.
            $may_use_stale = 429 === $code || $code >= 500;
        }

        if ($may_use_stale && is_array($lastgood) && isset($lastgood['templates'])) {
            return new WP_REST_Response([
                'templates' => $lastgood['templates'],
                'etag' => $lastgood['etag'] ?? '',
                'stale' => true,
                'available' => true,
            ]);
        }
        return new WP_REST_Response([
            'templates' => [],
            'etag' => '',
            'stale' => false,
            'available' => false,
        ]);
    }

    /**
     * GET a backend JSON endpoint with the org API key. Returns
     * ['status' => int, 'body' => array|null, 'etag' => string] or WP_Error.
     * Mirrors call_backend()'s SSRF guard + auth chokepoint for the read path.
     */
    private function call_backend_get(string $path, bool $require_auth = true, int $timeout = 15, array $extra_headers = [])
    {
        $settings = get_option('encypher_provenance_settings', []);
        $base_url = isset($settings['api_base_url']) ? rtrim((string) $settings['api_base_url'], '/') : '';
        if (! $base_url) {
            return new WP_Error('missing_configuration', __('Please configure the Encypher API base URL.', 'encypher-provenance'), ['status' => 400]);
        }
        if (! ApiUrlGuard::is_safe($base_url)) {
            return new WP_Error('unsafe_api_base_url', __('The configured Encypher API URL is not allowed (must be HTTPS and a public host).', 'encypher-provenance'), ['status' => 400]);
        }

        $headers = ['Accept' => 'application/json'];
        $api_key = isset($settings['api_key']) ? trim((string) $settings['api_key']) : '';
        if ($require_auth && $api_key) {
            $headers['Authorization'] = 'Bearer ' . sanitize_text_field($api_key);
        } elseif ($require_auth) {
            return new WP_Error('missing_api_key', __('Please configure an Encypher API key.', 'encypher-provenance'), ['status' => 401]);
        }
        foreach ($extra_headers as $k => $v) {
            $headers[$k] = $v;
        }

        $response = $this->guarded_remote_get($base_url . $path, ['headers' => $headers, 'timeout' => $timeout]);
        if (is_wp_error($response)) {
            return $response;
        }
        $status_code = (int) wp_remote_retrieve_response_code($response);
        $etag = (string) wp_remote_retrieve_header($response, 'etag');
        $raw = wp_remote_retrieve_body($response);
        $decoded = json_decode($raw, true);
        return [
            'status' => $status_code,
            'body' => is_array($decoded) ? $decoded : null,
            'etag' => $etag,
        ];
    }

    /**
     * Accept only the server's opaque evidence id paired with its canonical
     * HTTPS evidence route. Logical document and asset ids never qualify.
     *
     * @return array{rights_evidence_id:string,rights_resolution_url:string}|null
     */
    private static function validated_rights_evidence(array $result): ?array
    {
        $evidence_id = trim((string) ($result['rights_evidence_id'] ?? ''));
        $url = trim((string) ($result['rights_resolution_url'] ?? ''));
        if (
            ! preg_match('/\A[A-Za-z0-9][A-Za-z0-9._~-]{15,254}\z/D', $evidence_id)
            || '' === $url
            || preg_match('/[\x00-\x20\x7f]/', $url)
        ) {
            return null;
        }

        $parts = wp_parse_url($url);
        if (
            ! is_array($parts)
            || ! isset($parts['scheme'], $parts['host'], $parts['path'])
            || 'https' !== strtolower((string) $parts['scheme'])
            || '' === (string) $parts['host']
            || isset($parts['user'])
            || isset($parts['pass'])
            || isset($parts['query'])
            || isset($parts['fragment'])
        ) {
            return null;
        }

        $prefix = '/api/v1/public/rights/evidence/';
        $path = (string) $parts['path'];
        $offset = strlen($path) - strlen($prefix) - strlen($evidence_id);
        if ($offset < 0 || substr($path, $offset, strlen($prefix)) !== $prefix || substr($path, -strlen($evidence_id)) !== $evidence_id) {
            return null;
        }

        return [
            'rights_evidence_id' => $evidence_id,
            'rights_resolution_url' => $url,
        ];
    }

    /**
     * Persist one validated evidence pair in server-owned post meta. A
     * successful signing result without a valid pair clears stale evidence.
     *
     * @return array{rights_evidence_id:string,rights_resolution_url:string}|null
     */
    private static function persist_post_rights_evidence(int $post_id, array $result): ?array
    {
        $evidence = self::validated_rights_evidence($result);
        delete_post_meta($post_id, '_encypher_signed_rights_evidence');
        // Remove the superseded split-key representation. It is never read or
        // migrated into the trusted key.
        delete_post_meta($post_id, '_encypher_rights_evidence_id');
        delete_post_meta($post_id, '_encypher_rights_resolution_url');
        if (null === $evidence) {
            return null;
        }
        $encoded = function_exists('wp_json_encode')
            ? wp_json_encode($evidence)
            : json_encode($evidence); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress always provides wp_json_encode().
        update_post_meta($post_id, '_encypher_signed_rights_evidence', $encoded);
        return $evidence;
    }

    /**
     * Generation-fenced counterpart of persist_post_rights_evidence().
     *
     * @return array{result:string,evidence:array{rights_evidence_id:string,rights_resolution_url:string}|null}
     */
    private function project_post_rights_evidence(int $post_id, array $result): array
    {
        $evidence = self::validated_rights_evidence($result);
        foreach ([
            '_encypher_signed_rights_evidence',
            '_encypher_rights_evidence_id',
            '_encypher_rights_resolution_url',
        ] as $meta_key) {
            $write_result = $this->delete_generation_meta($post_id, $meta_key);
            if (! $this->write_result_succeeded($write_result)) {
                return ['result' => $write_result, 'evidence' => null];
            }
        }
        if (null === $evidence) {
            return ['result' => self::WRITE_OWNED_NO_CHANGE, 'evidence' => null];
        }
        $encoded = function_exists('wp_json_encode')
            ? wp_json_encode($evidence)
            : json_encode($evidence); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress always provides wp_json_encode().
        $write_result = $this->project_generation_meta(
            $post_id,
            '_encypher_signed_rights_evidence',
            $encoded
        );
        return [
            'result' => $write_result,
            'evidence' => $this->write_result_succeeded($write_result) ? $evidence : null,
        ];
    }

    /**
     * Rate-limit permission callback for the public /verify endpoint.
     * See rate_limit_public_endpoint() for the mechanism.
     */
    public function rate_limit_verify(WP_REST_Request $request)
    {
        return $this->rate_limit_public_endpoint('verify', 30, 60);
    }

    /**
     * Rate-limit reader-triggered verification of signed media originals.
     */
    public function rate_limit_verify_media(WP_REST_Request $request)
    {
        return $this->rate_limit_public_endpoint('verify-media', 30, 60);
    }

    /**
     * Rate-limit permission callback for the public /extract endpoint.
     * Lower ceiling than /verify: this one accepts arbitrary caller-supplied
     * text up to 200,000 bytes per request, so it is the more expensive of
     * the two to abuse.
     */
    public function rate_limit_extract(WP_REST_Request $request)
    {
        return $this->rate_limit_public_endpoint('extract', 20, 60);
    }

    /**
     * Rate-limit permission callback for the public /provenance endpoint.
     * This can trigger a live outbound verify call (see
     * handle_provenance_request()'s cache-repopulation path), so it shares
     * the same protection as /verify.
     */
    public function rate_limit_provenance(WP_REST_Request $request)
    {
        return $this->rate_limit_public_endpoint('provenance', 30, 60);
    }

    /**
     * Fixed-window rate limiter for public, unauthenticated REST endpoints
     * that trigger outbound backend API calls and/or DB writes (/verify,
     * /extract). Without this, an unauthenticated caller can enumerate post
     * IDs or submit large texts to force outbound requests, DB writes, and
     * remote verification-event sync attempts at no cost to themselves.
     *
     * Implemented with a single WP transient per (route, client IP) pair —
     * no external dependency required. On a multi-node deployment without a
     * shared persistent object cache, each node keeps its own local counter,
     * which under-counts the true per-client rate across the whole site; this
     * is an accepted, documented tradeoff over adding a hard dependency on
     * Redis/Memcached for a plugin that must run on ordinary shared hosting.
     *
     * @return true|WP_Error
     */
    private function rate_limit_public_endpoint(string $route_key, int $max_requests, int $window_seconds)
    {
        $ip = $this->client_ip_for_rate_limit();
        $key = 'encypher_rl_' . $route_key . '_' . md5($ip);
        $now = time();
        $state = get_transient($key);

        if (! is_array($state) || ! isset($state['window_start'], $state['count'])
            || ($now - (int) $state['window_start']) >= $window_seconds) {
            // No window yet, or the previous one has fully elapsed: start fresh.
            set_transient($key, ['window_start' => $now, 'count' => 1], $window_seconds);
            return true;
        }

        if ((int) $state['count'] >= $max_requests) {
            $retry_after = max(1, $window_seconds - ($now - (int) $state['window_start']));
            return new WP_Error(
                'rate_limited',
                __('Too many requests. Please try again shortly.', 'encypher-provenance'),
                ['status' => 429, 'retry_after' => $retry_after]
            );
        }

        $state['count'] = (int) $state['count'] + 1;
        // Preserve the ORIGINAL window's remaining TTL rather than a fresh
        // window_seconds, so the transient expires in sync with
        // window_start + window_seconds instead of being pushed out further
        // by every request (which would make this a never-expiring sliding
        // window under sustained load instead of a true fixed window).
        $remaining_ttl = max(1, $window_seconds - ($now - (int) $state['window_start']));
        set_transient($key, $state, $remaining_ttl);
        return true;
    }

    /**
     * Client IP used as the rate-limit bucket key.
     *
     * Defaults to REMOTE_ADDR, which is not attacker-spoofable but is also
     * not the real visitor behind a reverse proxy (Railway, Cloudflare,
     * nginx, a hosting-provider load balancer): REMOTE_ADDR there is the
     * proxy's own address, collapsing every visitor into one shared bucket
     * (one abusive caller can exhaust the quota for the whole site), or —
     * with a rotating edge-node pool — handing an attacker many buckets
     * instead of one. Only a deployment that KNOWS its own proxy chain can
     * safely trust a forwarded-for style header (trusting one unconditionally
     * would let any client simply forge it and reset their own limit), so
     * this is exposed as a filter rather than auto-detected: a host-specific
     * mu-plugin or wp-config snippet can opt in with
     * `add_filter('encypher_provenance_rate_limit_client_ip', fn() => $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');`
     * once it has verified traffic only reaches PHP through that trusted
     * proxy. Unfiltered, this stays REMOTE_ADDR — the safe default for
     * ordinary shared hosting with no reverse proxy in front of PHP.
     */
    private function client_ip_for_rate_limit(): string
    {
        // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ -- REMOTE_ADDR is the safe default (see docblock); forwarded headers only via the documented filter. Used solely as a per-IP rate-limit bucket key.
        $default_ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '0.0.0.0';
        $ip = apply_filters('encypher_provenance_rate_limit_client_ip', $default_ip);
        return is_string($ip) && '' !== $ip ? $ip : $default_ip;
    }

    /**
     * Process-local serialization is an optimization only. Storage-conditional
     * SQL below is the cross-process correctness fence.
     */
    private function acquire_text_projection_mutex(int $post_id): string
    {
        global $wpdb;
        $prefix = isset($wpdb->prefix) ? (string) $wpdb->prefix : '';
        $blog_id = function_exists('get_current_blog_id') ? (string) get_current_blog_id() : '0';
        $name = 'encypher_text_' . substr(hash('sha256', $prefix . '|' . $blog_id . '|' . $post_id), 0, 32);
        if (isset(self::$held_text_projection_mutexes[$name])) {
            return '';
        }
        self::$held_text_projection_mutexes[$name] = true;
        return $name;
    }

    private function release_text_projection_mutex(string $name): void
    {
        if ('' !== $name) {
            unset(self::$held_text_projection_mutexes[$name]);
        }
    }


    /**
     * Start a fenced text-generation commit. Initializing and terminalizing
     * leases cannot be superseded while live, so post meta is projected only
     * while this exact owner has an exclusive mutation window.
     */
    private function begin_text_generation(int $post_id): ?string
    {
        $mutex = $this->acquire_text_projection_mutex($post_id);
        if ('' === $mutex) {
            return null;
        }

        try {
            $key = 'encypher_text_generation_' . $post_id;
            $generation = $this->acquire_option_lease($key, (string) $post_id, 'initializing');
            if (null === $generation) {
                $generation = $this->supersede_option_lease($key, (string) $post_id, 'initializing');
            }
            if (null === $generation) {
                return null;
            }

            $request_local = 0 === strpos($generation, 'request-local:');
            $initializing_lease = $request_local ? $generation : (string) get_option($key, '');
            if (! $request_local) {
                $decoded_initializing_lease = $this->decode_option_lease($initializing_lease);
                if (
                    ! hash_equals($generation, (string) ($decoded_initializing_lease['token'] ?? ''))
                    || 'initializing' !== (string) ($decoded_initializing_lease['phase'] ?? '')
                ) {
                    $this->release_text_generation($post_id, $generation);
                    return null;
                }
            }

            $this->text_generation_checkpoint('begin_pending', $post_id, $generation);
            if ($request_local) {
                update_post_meta($post_id, '_encypher_provenance_generation_pending', $generation);
                $pending_result = self::WRITE_MUTATED;
            } else {
                $pending_result = $this->initialize_generation_meta(
                    $post_id,
                    '_encypher_provenance_generation_pending',
                    $generation,
                    $generation,
                    $initializing_lease,
                    false
                );
            }
            if (! $this->write_result_succeeded($pending_result)) {
                $this->release_text_generation($post_id, $generation);
                return null;
            }

            $this->text_generation_checkpoint('begin_status', $post_id, $generation);
            if ($request_local) {
                update_post_meta($post_id, '_encypher_provenance_status', 'signing');
                $status_result = self::WRITE_MUTATED;
            } else {
                $status_result = $this->initialize_generation_meta(
                    $post_id,
                    '_encypher_provenance_status',
                    'signing',
                    $generation,
                    $initializing_lease,
                    true
                );
            }
            if (! $this->write_result_succeeded($status_result)) {
                delete_post_meta($post_id, '_encypher_provenance_generation_pending', $generation);
                $this->release_text_generation($post_id, $generation);
                return null;
            }

            $this->text_generation_checkpoint('begin_activate', $post_id, $generation);
            if (
                ! $this->owns_option_lease_phase($key, $generation, 'initializing')
                || ! hash_equals($generation, (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true))
                || ! $this->transition_option_lease_phase($key, $generation, 'initializing', 'active')
            ) {
                delete_post_meta($post_id, '_encypher_provenance_generation_pending', $generation);
                $this->release_text_generation($post_id, $generation);
                return null;
            }
            return $generation;
        } finally {
            $this->release_text_projection_mutex($mutex);
        }
    }
    private function initialize_generation_meta(
        int $post_id,
        string $meta_key,
        string $value,
        string $generation,
        string $initializing_lease,
        bool $require_pending
    ): string {
        global $wpdb;
        if (
            ! isset($wpdb->options, $wpdb->postmeta)
            || ! is_callable([$wpdb, 'prepare'])
            || ! is_callable([$wpdb, 'query'])
        ) {
            if (! hash_equals($initializing_lease, (string) get_option('encypher_text_generation_' . $post_id, ''))) {
                return self::WRITE_SUPERSEDED;
            }
            $updated = update_post_meta($post_id, $meta_key, $value);
            if (false !== $updated) {
                return self::WRITE_MUTATED;
            }
            return get_post_meta($post_id, $meta_key, true) == $value
                ? self::WRITE_OWNED_NO_CHANGE
                : self::WRITE_DB_ERROR;
        }
        $pending_join = $require_pending
            ? " INNER JOIN {$wpdb->postmeta} pending
                    ON pending.post_id = target.post_id
                   AND pending.meta_key = %s AND pending.meta_value = %s"
            : '';
        $update_args = ['encypher_text_generation_' . $post_id, $initializing_lease];
        if ($require_pending) {
            $update_args[] = '_encypher_provenance_generation_pending';
            $update_args[] = $generation;
        }
        $update_args[] = $value;
        $update_args[] = $post_id;
        $update_args[] = $meta_key;
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- initialization is fenced by the exact encoded lease in the same statement.
        $updated = $wpdb->query($wpdb->prepare(
            "UPDATE {$wpdb->postmeta} target
             INNER JOIN {$wpdb->options} lease
                ON lease.option_name = %s AND lease.option_value = %s{$pending_join}
             SET target.meta_value = %s
             WHERE target.post_id = %d AND target.meta_key = %s",
            ...$update_args
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        if ($this->wpdb_query_failed($wpdb, $updated)) {
            return self::WRITE_DB_ERROR;
        }
        if ((int) $updated > 0) {
            $this->invalidate_generation_meta_cache($post_id);
            return self::WRITE_MUTATED;
        }

        $pending_insert_join = $require_pending
            ? " INNER JOIN {$wpdb->postmeta} pending
                    ON pending.post_id = %d
                   AND pending.meta_key = %s AND pending.meta_value = %s"
            : '';
        $insert_args = [$post_id, $meta_key, $value, 'encypher_text_generation_' . $post_id, $initializing_lease];
        if ($require_pending) {
            array_splice($insert_args, 3, 0, [$post_id, '_encypher_provenance_generation_pending', $generation]);
        }
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $inserted = $wpdb->query($wpdb->prepare(
            "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
             SELECT %d, %s, %s FROM {$wpdb->options} lease{$pending_insert_join}
             WHERE lease.option_name = %s AND lease.option_value = %s
               AND NOT EXISTS (
                   SELECT 1 FROM {$wpdb->postmeta} existing
                   WHERE existing.post_id = %d AND existing.meta_key = %s
               )",
            ...array_merge($insert_args, [$post_id, $meta_key])
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        if ($this->wpdb_query_failed($wpdb, $inserted)) {
            return self::WRITE_DB_ERROR;
        }
        if ((int) $inserted > 0) {
            $this->invalidate_generation_meta_cache($post_id);
            return self::WRITE_MUTATED;
        }
        return hash_equals($initializing_lease, (string) get_option('encypher_text_generation_' . $post_id, ''))
            && (! $require_pending || hash_equals($generation, (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true)))
            ? self::WRITE_OWNED_NO_CHANGE
            : self::WRITE_SUPERSEDED;
    }

    /**
     * Project the held protected-update candidate onto a post snapshot.
     *
     * The post row continues to serve the last committed generation while the
     * candidate is signing, so generation fences must compare these candidate
     * fields rather than the held row values.
     *
     * @param mixed $assurance
     */
    private function apply_protected_signing_candidate(object $post, $assurance): object
    {
        if (! is_array($assurance) || 'preparing_update' !== (string) ($assurance['state'] ?? '')) {
            return $post;
        }
        $candidate = clone $post;
        $candidate->post_title = (string) ($assurance['candidate_title'] ?? $post->post_title);
        $candidate->post_excerpt = (string) ($assurance['candidate_excerpt'] ?? $post->post_excerpt);
        $candidate->post_content = (string) ($assurance['candidate_content'] ?? $post->post_content);
        return $candidate;
    }

    private function candidate_generation_digest(object $post, string $clean_content): string
    {
        return hash(
            'sha256',
            (string) ($post->post_title ?? '')
                . "\0"
                . (string) ($post->post_excerpt ?? '')
                . "\0"
                . $clean_content
        );
    }

    private function previous_signed_source_text(int $post_id): string
    {
        $source = get_post_meta($post_id, '_encypher_signed_source_text', true);
        if (is_string($source) && '' !== $source) {
            return $source;
        }
        $snapshot = get_post_meta($post_id, '_encypher_signed_text', true);
        return is_string($snapshot) && '' !== $snapshot
            ? $this->extract_text_for_verify($snapshot)
            : '';
    }

    /**
     * Keep a protected edit's marker-free candidate off the public post row
     * until its exact terminal generation commits.
     */
    private function project_or_stage_generation_content(
        int $post_id,
        string $generation,
        string $content
    ): string {
        $assurance = get_post_meta($post_id, '_encypher_publication_assurance', true);
        if (
            is_array($assurance)
            && 'preparing_update' === (string) ($assurance['state'] ?? '')
            && array_key_exists('held_content', $assurance)
        ) {
            $staged = $this->staged_generation_contents[$post_id] ?? null;
            // A signed-image rewrite replaces the staged output, not the held
            // editorial input that detects a newer protected edit.
            $candidate_content = is_array($staged)
                && hash_equals($generation, (string) ($staged['generation'] ?? ''))
                    ? (string) ($staged['candidate_content'] ?? ($staged['content'] ?? $content))
                    : $content;
            $this->staged_generation_contents[$post_id] = [
                'generation' => $generation,
                'content' => $content,
                'candidate_content' => $candidate_content,
                'held_content' => (string) $assurance['held_content'],
            ];
            return self::WRITE_MUTATED;
        }
        return $this->project_generation_content($post_id, $content);
    }

    /**
     * Stage the protected candidate's marker-bearing pair beside its clean
     * content. The prior committed pair remains readable until terminal commit.
     *
     * @return array{0:string,1:string}
     */
    private function project_or_stage_signed_snapshot(
        int $post_id,
        string $generation,
        string $signed_text,
        string $signed_source_text
    ): array {
        $content = $this->staged_generation_contents[$post_id] ?? null;
        if (
            is_array($content)
            && hash_equals($generation, (string) ($content['generation'] ?? ''))
        ) {
            $meta = [];
            foreach ([
                '_encypher_signed_text' => $signed_text,
                '_encypher_signed_source_text' => $signed_source_text,
            ] as $meta_key => $value) {
                $meta[$meta_key] = [
                    'previous_exists' => $this->attachment_meta_exists($post_id, $meta_key),
                    'previous' => get_post_meta($post_id, $meta_key, true),
                    'value' => $value,
                ];
            }
            $this->staged_generation_snapshots[$post_id] = [
                'generation' => $generation,
                'meta' => $meta,
            ];
            return [self::WRITE_OWNED_NO_CHANGE, self::WRITE_OWNED_NO_CHANGE];
        }
        return [
            $this->project_generation_meta($post_id, '_encypher_signed_text', $signed_text),
            $this->project_generation_meta($post_id, '_encypher_signed_source_text', $signed_source_text),
        ];
    }

    private function has_staged_generation_snapshot(int $post_id, string $generation): bool
    {
        $staged = $this->staged_generation_snapshots[$post_id] ?? null;
        return is_array($staged)
            && hash_equals($generation, (string) ($staged['generation'] ?? ''));
    }

    private function restore_staged_generation_snapshot(int $post_id, string $generation): void
    {
        $staged = $this->staged_generation_snapshots[$post_id] ?? null;
        if (
            ! is_array($staged)
            || ! hash_equals($generation, (string) ($staged['generation'] ?? ''))
        ) {
            return;
        }
        foreach (array_reverse($staged['meta'] ?? [], true) as $meta_key => $entry) {
            if (! empty($entry['previous_exists'])) {
                $this->project_generation_meta($post_id, (string) $meta_key, $entry['previous']);
            } else {
                $this->delete_generation_meta($post_id, (string) $meta_key);
            }
        }
    }

    private function discard_staged_generation_content(int $post_id, string $generation): void
    {
        $staged = $this->staged_generation_contents[$post_id] ?? null;
        if (
            is_array($staged)
            && hash_equals($generation, (string) ($staged['generation'] ?? ''))
        ) {
            unset($this->staged_generation_contents[$post_id]);
        }
        $snapshot = $this->staged_generation_snapshots[$post_id] ?? null;
        if (
            is_array($snapshot)
            && hash_equals($generation, (string) ($snapshot['generation'] ?? ''))
        ) {
            unset($this->staged_generation_snapshots[$post_id]);
        }
    }

    /**
     * @param array<string,mixed> $attempt
     */
    private function owns_text_attempt(int $post_id, string $generation, array $attempt): bool
    {
        if (! $this->owns_text_generation($post_id, $generation)) {
            return false;
        }
        if (! function_exists(__NAMESPACE__ . '\\get_post') && ! function_exists('get_post')) {
            return true;
        }
        $post = get_post($post_id);
        if (! $post) {
            return false;
        }
        $revision = (string) ($post->post_modified_gmt ?? ($post->post_modified ?? ''));
        if (! hash_equals((string) ($attempt['revision'] ?? ''), $revision)) {
            return false;
        }
        $assurance = get_post_meta($post_id, '_encypher_publication_assurance', true);
        $post = $this->apply_protected_signing_candidate($post, $assurance);
        $current_clean = $this->sanitize_wp_block_comments(
            $this->strip_c2pa_embeddings((string) $post->post_content)
        );
        $staged = $this->staged_generation_contents[$post_id] ?? null;
        if (
            is_array($staged)
            && hash_equals($generation, (string) ($staged['generation'] ?? ''))
        ) {
            if (! hash_equals((string) ($staged['candidate_content'] ?? ''), $current_clean)) {
                return false;
            }
            $current_clean = (string) ($staged['content'] ?? '');
        }
        if (! hash_equals(
            (string) ($attempt['candidate_digest'] ?? ''),
            $this->candidate_generation_digest($post, $current_clean)
        )) {
            return false;
        }
        if (isset($attempt['editorial_digest'])) {
            $editorial = EditorialProvenance::for_post($post_id);
            // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress runtime always provides wp_json_encode().
            $encoded = function_exists('wp_json_encode') ? wp_json_encode($editorial) : json_encode($editorial);
            if (! hash_equals((string) $attempt['editorial_digest'], hash('sha256', (string) $encoded))) {
                return false;
            }
        }
        if (isset($attempt['article_metadata_digest'])) {
            $article_metadata = EditorialMetadata::build_assertion_data($post_id);
            // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress runtime always provides wp_json_encode().
            $encoded = function_exists('wp_json_encode') ? wp_json_encode($article_metadata) : json_encode($article_metadata);
            if (! hash_equals((string) $attempt['article_metadata_digest'], hash('sha256', (string) $encoded))) {
                return false;
            }
        }
        if (isset($attempt['previous_instance_id']) && ! hash_equals(
            (string) $attempt['previous_instance_id'],
            (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true)
        )) {
            return false;
        }
        if (isset($attempt['previous_signed_content_hash']) && ! hash_equals(
            (string) $attempt['previous_signed_content_hash'],
            hash('sha256', $this->previous_signed_source_text($post_id))
        )) {
            return false;
        }
        foreach (($attempt['media'] ?? []) as $media_input) {
            $file_path = (string) ($media_input['file_path'] ?? '');
            if (
                '' === $file_path
                || ! is_file($file_path)
                || ! hash_equals((string) ($media_input['hash'] ?? ''), (string) hash_file('sha256', $file_path))
            ) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param array<int,array<string,mixed>> $media_items
     * @return array<string,mixed>
     */
    private function capture_text_attempt(
        object $post,
        string $clean_content,
        array $media_items,
        array $context = []
    ): array {
        $media = [];
        foreach ($media_items as $item) {
            $file_path = (string) ($item['file_path'] ?? '');
            $original = $item['_encypher_original_bytes'] ?? null;
            if ('' === $file_path || ! is_string($original)) {
                continue;
            }
            $media[] = [
                'attachment_id' => (int) ($item['attachment_id'] ?? 0),
                'file_path' => $file_path,
                'hash' => hash('sha256', $original),
            ];
        }
        $editorial = EditorialProvenance::for_post((int) ($post->ID ?? 0));
        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress runtime always provides wp_json_encode().
        $encoded_editorial = function_exists('wp_json_encode') ? wp_json_encode($editorial) : json_encode($editorial);
        $attempt = [
            'revision' => (string) ($post->post_modified_gmt ?? ($post->post_modified ?? '')),
            'candidate_digest' => $this->candidate_generation_digest($post, $clean_content),
            'editorial_digest' => hash('sha256', (string) $encoded_editorial),
            'article_metadata_digest' => hash(
                'sha256',
                (string) (
                    function_exists('wp_json_encode')
                        ? wp_json_encode(EditorialMetadata::build_assertion_data((int) ($post->ID ?? 0)))
                        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback.
                        : json_encode(EditorialMetadata::build_assertion_data((int) ($post->ID ?? 0)))
                )
            ),
            'media' => $media,
        ];
        if (array_key_exists('previous_instance_id', $context)) {
            $attempt['previous_instance_id'] = (string) $context['previous_instance_id'];
        }
        if (array_key_exists('previous_signed_content', $context)) {
            $attempt['previous_signed_content_hash'] = hash('sha256', (string) $context['previous_signed_content']);
        }
        return $attempt;
    }

    private function text_generation_checkpoint(string $checkpoint, int $post_id, string $generation): void
    {
        if (function_exists(__NAMESPACE__ . '\\do_action') || function_exists('do_action')) {
            do_action('encypher_text_generation_checkpoint', $checkpoint, $post_id, $generation);
        }
    }

    /**
     * Open one response-side mutation boundary only while the captured input
     * and its generation lease are still authoritative.
     *
     * @param array<string,mixed> $attempt
     */
    private function owns_text_mutation(
        string $checkpoint,
        int $post_id,
        string $generation,
        array $attempt
    ): bool {
        $this->text_generation_checkpoint($checkpoint, $post_id, $generation);
        return $this->owns_text_attempt($post_id, $generation, $attempt);
    }

    private function owns_text_generation(int $post_id, string $generation): bool
    {
        $pending = (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true);
        if ('' === $pending || ! hash_equals($generation, $pending)) {
            return false;
        }
        if (isset($this->text_projection_terminals[$post_id])) {
            return $this->owns_text_terminal(
                'projection_owner',
                $post_id,
                $generation,
                $this->text_projection_terminals[$post_id]
            );
        }
        return $this->owns_option_lease_phase(
            'encypher_text_generation_' . $post_id,
            $generation,
            'active'
        );
    }

    private function text_generation_fenced_error(): WP_Error
    {
        return new WP_Error(
            'signing_superseded',
            __('A newer signing generation superseded this result.', 'encypher-provenance'),
            ['status' => 409]
        );
    }


    private function fail_text_generation(int $post_id, string $generation, WP_Error $error): WP_Error
    {
        $abort = $this->abort_text_generation($post_id, $generation, 'signing_failed');
        if ($this->write_result_succeeded($abort['result'])) {
            return $this->add_terminal_cleanup_warning_to_error($error, $abort['cleanup']);
        }
        return $this->write_result_error($abort['result']);
    }
    private function release_text_generation(int $post_id, string $generation): void
    {
        $this->release_option_lease('encypher_text_generation_' . $post_id, $generation);
    }

    /**
     * Move an exact active generation into an un-supersedable terminal phase.
     *
     * @return string Exact encoded terminal lease, or an empty string.
     */
    private function terminalize_text_generation(int $post_id, string $generation): string
    {
        $key = 'encypher_text_generation_' . $post_id;
        $this->text_generation_checkpoint('terminalize', $post_id, $generation);
        if (
            ! hash_equals($generation, (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true))
            || ! $this->owns_option_lease_phase($key, $generation, 'active')
        ) {
            return '';
        }
        return $this->transition_option_lease_phase($key, $generation, 'active', 'terminalizing', true);
    }

    /**
     * Return a scope guard so every response return and exception releases the
     * advisory mutex. Connection death provides the hard crash release.
     *
     * @param array<string,mixed> $attempt
     */
    private function open_text_projection_guard(int $post_id, string $generation, array $attempt): ?object
    {
        if (! $this->begin_text_projection($post_id, $generation, $attempt)) {
            return null;
        }
        $release = function () use ($post_id): void {
            $this->end_text_projection($post_id);
        };
        return new class ($release) {
            /** @var callable */
            private $release;

            public function __construct(callable $release)
            {
                $this->release = $release;
            }

            public function __destruct()
            {
                ($this->release)();
            }
        };
    }
    /**
     * Enter the only response-owned post mutation window. The active lease is
     * rechecked and terminalized while the shared advisory mutex is held.
     *
     * @param array<string,mixed> $attempt
     */
    private function begin_text_projection(int $post_id, string $generation, array $attempt): bool
    {
        $mutex = $this->acquire_text_projection_mutex($post_id);
        if ('' === $mutex) {
            return false;
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            $this->release_text_projection_mutex($mutex);
            return false;
        }
        $terminal = $this->terminalize_text_generation($post_id, $generation);
        if ('' === $terminal) {
            $this->release_text_projection_mutex($mutex);
            return false;
        }
        $this->text_projection_mutexes[$post_id] = $mutex;
        $this->text_projection_terminals[$post_id] = $terminal;
        unset($this->unwound_text_projection_terminals[$post_id]);
        return true;
    }

    private function end_text_projection(int $post_id): void
    {
        $mutex = $this->text_projection_mutexes[$post_id] ?? '';
        $terminal = $this->text_projection_terminals[$post_id] ?? '';
        if ('' !== $terminal) {
            $this->unwound_text_projection_terminals[$post_id] = $terminal;
        }
        unset($this->text_projection_mutexes[$post_id], $this->text_projection_terminals[$post_id]);
        $this->release_text_projection_mutex($mutex);
    }

    /**
     * Mutate generation-owned post meta with the option lease and pending
     * generation checked by the same MySQL statement as the write.
     *
     * The metadata API cannot express this CAS. Callers outside a terminal
     * projection retain the normal WordPress API path.
     *
     * @param mixed $value
     */
    private function project_generation_meta(int $post_id, string $meta_key, $value): string
    {
        $terminal = $this->text_projection_terminals[$post_id] ?? '';
        if ('' === $terminal || 0 === strpos($terminal, 'request-local:')) {
            $updated = \update_post_meta($post_id, $meta_key, $value);
            if (false !== $updated) {
                return self::WRITE_MUTATED;
            }
            return get_post_meta($post_id, $meta_key, true) == $value
                ? self::WRITE_OWNED_NO_CHANGE
                : self::WRITE_DB_ERROR;
        }

        global $wpdb;
        $generation = $this->terminal_generation($terminal);
        if ('' === $generation) {
            return self::WRITE_SUPERSEDED;
        }
        if (
            ! isset($wpdb->options, $wpdb->postmeta)
            || ! is_callable([$wpdb, 'prepare'])
            || ! is_callable([$wpdb, 'query'])
        ) {
            if (! $this->owns_text_terminal('fallback_meta', $post_id, $generation, $terminal)) {
                return self::WRITE_SUPERSEDED;
            }
            $updated = \update_post_meta($post_id, $meta_key, $value);
            if (false !== $updated) {
                return self::WRITE_MUTATED;
            }
            return get_post_meta($post_id, $meta_key, true) == $value
                ? self::WRITE_OWNED_NO_CHANGE
                : self::WRITE_DB_ERROR;
        }

        $serialized = function_exists('maybe_serialize')
            ? maybe_serialize($value)
            : (is_scalar($value) ? (string) $value : wp_json_encode($value));
        $option_name = 'encypher_text_generation_' . $post_id;
        $pending_key = '_encypher_provenance_generation_pending';
        $this->text_generation_checkpoint('conditional_meta_' . $meta_key, $post_id, $generation);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are wpdb-owned; the exact lease and pending owner are bound parameters. Direct SQL is the storage-level CAS.
        $updated = $wpdb->query($wpdb->prepare(
            "UPDATE {$wpdb->postmeta} target
             INNER JOIN {$wpdb->options} lease
                ON lease.option_name = %s AND lease.option_value = %s
             INNER JOIN {$wpdb->postmeta} pending
                ON pending.post_id = target.post_id
               AND pending.meta_key = %s AND pending.meta_value = %s
             SET target.meta_value = %s
             WHERE target.post_id = %d AND target.meta_key = %s",
            $option_name,
            $terminal,
            $pending_key,
            $generation,
            $serialized,
            $post_id,
            $meta_key
        ));
        if ($this->wpdb_query_failed($wpdb, $updated)) {
            return self::WRITE_DB_ERROR;
        }

        $inserted = 0;
        if (0 === (int) $updated) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- same storage-level CAS as the UPDATE; NOT EXISTS prevents a second row for this key.
            $inserted = $wpdb->query($wpdb->prepare(
                "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
                 SELECT DISTINCT %d, %s, %s
                 FROM {$wpdb->options} lease
                 INNER JOIN {$wpdb->postmeta} pending
                    ON pending.post_id = %d
                   AND pending.meta_key = %s AND pending.meta_value = %s
                 WHERE lease.option_name = %s AND lease.option_value = %s
                   AND NOT EXISTS (
                       SELECT 1 FROM {$wpdb->postmeta} existing
                       WHERE existing.post_id = %d AND existing.meta_key = %s
                   )",
                $post_id,
                $meta_key,
                $serialized,
                $post_id,
                $pending_key,
                $generation,
                $option_name,
                $terminal,
                $post_id,
                $meta_key
            ));
            if ($this->wpdb_query_failed($wpdb, $inserted)) {
                return self::WRITE_DB_ERROR;
            }
        }
        if ((int) $updated > 0 || (int) $inserted > 0) {
            $this->invalidate_generation_meta_cache($post_id);
            return self::WRITE_MUTATED;
        }
        return $this->owns_text_storage_fence($post_id, $generation, $terminal);
    }

    private function delete_generation_meta(int $post_id, string $meta_key, $meta_value = null): string
    {
        $terminal = $this->text_projection_terminals[$post_id] ?? '';
        if ('' === $terminal || 0 === strpos($terminal, 'request-local:')) {
            $deleted = \delete_post_meta($post_id, $meta_key, $meta_value);
            if (false !== $deleted) {
                return self::WRITE_MUTATED;
            }
            return '' === (string) get_post_meta($post_id, $meta_key, true)
                ? self::WRITE_OWNED_NO_CHANGE
                : self::WRITE_DB_ERROR;
        }

        global $wpdb;
        $generation = $this->terminal_generation($terminal);
        if ('' === $generation) {
            return self::WRITE_SUPERSEDED;
        }
        if (
            ! isset($wpdb->options, $wpdb->postmeta)
            || ! is_callable([$wpdb, 'prepare'])
            || ! is_callable([$wpdb, 'query'])
        ) {
            if (! $this->owns_text_terminal('fallback_delete', $post_id, $generation, $terminal)) {
                return self::WRITE_SUPERSEDED;
            }
            $deleted = \delete_post_meta($post_id, $meta_key, $meta_value);
            if (false !== $deleted) {
                return self::WRITE_MUTATED;
            }
            return '' === (string) get_post_meta($post_id, $meta_key, true)
                ? self::WRITE_OWNED_NO_CHANGE
                : self::WRITE_DB_ERROR;
        }
        $this->text_generation_checkpoint('conditional_delete_' . $meta_key, $post_id, $generation);
        $value_clause = '';
        $args = [
            'encypher_text_generation_' . $post_id,
            $terminal,
            '_encypher_provenance_generation_pending',
            $generation,
            $post_id,
            $meta_key,
        ];
        if (null !== $meta_value && '' !== $meta_value) {
            $value_clause = ' AND target.meta_value = %s';
            $args[] = function_exists('maybe_serialize')
                ? maybe_serialize($meta_value)
                : (is_scalar($meta_value) ? (string) $meta_value : wp_json_encode($meta_value));
        }
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are wpdb-owned; direct SQL makes ownership and deletion one statement.
        $deleted = $wpdb->query($wpdb->prepare(
            "DELETE target FROM {$wpdb->postmeta} target
             INNER JOIN {$wpdb->options} lease
                ON lease.option_name = %s AND lease.option_value = %s
             INNER JOIN {$wpdb->postmeta} pending
                ON pending.post_id = target.post_id
               AND pending.meta_key = %s AND pending.meta_value = %s
             WHERE target.post_id = %d AND target.meta_key = %s{$value_clause}",
            ...$args
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        if ($this->wpdb_query_failed($wpdb, $deleted)) {
            return self::WRITE_DB_ERROR;
        }
        if ((int) $deleted > 0) {
            $this->invalidate_generation_meta_cache($post_id);
            return self::WRITE_MUTATED;
        }
        return $this->owns_text_storage_fence($post_id, $generation, $terminal);
    }

    private function project_generation_content(int $post_id, string $content): string
    {
        $terminal = $this->text_projection_terminals[$post_id] ?? '';
        if ('' === $terminal || 0 === strpos($terminal, 'request-local:')) {
            $updated = \wp_update_post(['ID' => $post_id, 'post_content' => $content], true);
            return is_wp_error($updated) ? self::WRITE_DB_ERROR : self::WRITE_MUTATED;
        }

        global $wpdb;
        $generation = $this->terminal_generation($terminal);
        if ('' === $generation) {
            return self::WRITE_SUPERSEDED;
        }
        if (
            ! isset($wpdb->posts, $wpdb->postmeta, $wpdb->options)
            || ! is_callable([$wpdb, 'prepare'])
            || ! is_callable([$wpdb, 'query'])
        ) {
            if (! $this->owns_text_terminal('fallback_content', $post_id, $generation, $terminal)) {
                return self::WRITE_SUPERSEDED;
            }
            $updated = \wp_update_post(['ID' => $post_id, 'post_content' => $content], true);
            return is_wp_error($updated) ? self::WRITE_DB_ERROR : self::WRITE_MUTATED;
        }
        $post_before = get_post($post_id);
        $post_modified = current_time('mysql');
        $post_modified_gmt = gmdate('Y-m-d H:i:s');
        $this->text_generation_checkpoint('conditional_content', $post_id, $generation);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are wpdb-owned; direct SQL makes the post write and both ownership checks one statement.
        $updated = $wpdb->query($wpdb->prepare(
            "UPDATE {$wpdb->posts} target
             INNER JOIN {$wpdb->options} lease
                ON lease.option_name = %s AND lease.option_value = %s
             INNER JOIN {$wpdb->postmeta} pending
                ON pending.post_id = target.ID
               AND pending.meta_key = %s AND pending.meta_value = %s
             SET target.post_content = %s,
                 target.post_modified = %s,
                 target.post_modified_gmt = %s
             WHERE target.ID = %d",
            'encypher_text_generation_' . $post_id,
            $terminal,
            '_encypher_provenance_generation_pending',
            $generation,
            $content,
            $post_modified,
            $post_modified_gmt,
            $post_id
        ));
        if ($this->wpdb_query_failed($wpdb, $updated)) {
            return self::WRITE_DB_ERROR;
        }
        if (0 === (int) $updated) {
            return $this->owns_text_storage_fence($post_id, $generation, $terminal);
        }
        if (function_exists('clean_post_cache')) {
            clean_post_cache($post_id);
        }
        $post_after = get_post($post_id);
        $this->emit_conditional_post_update_hooks($post_id, $post_before, $post_after);
        return self::WRITE_MUTATED;
    }
    /**
     * @param mixed $wpdb
     * @param mixed $result
     */
    private function wpdb_query_failed($wpdb, $result): bool
    {
        return false === $result || (isset($wpdb->last_error) && '' !== (string) $wpdb->last_error);
    }

    private function write_result_succeeded(string $result): bool
    {
        return self::WRITE_MUTATED === $result || self::WRITE_OWNED_NO_CHANGE === $result;
    }

    private function write_result_error(string $result): WP_Error
    {
        if (self::WRITE_DB_ERROR === $result) {
            return new WP_Error(
                'projection_database_error',
                __('WordPress could not persist the signed result.', 'encypher-provenance'),
                ['status' => 500]
            );
        }
        return $this->text_generation_fenced_error();
    }
    /**
     * @param array<string,mixed> $data
     * @return array<string,mixed>
     */
    private function add_terminal_cleanup_warning_to_data(array $data, string $cleanup): array
    {
        if ($this->write_result_succeeded($cleanup)) {
            return $data;
        }
        $data['warnings'][] = [
            'code' => 'terminal_lease_cleanup_failed',
            'detail' => $cleanup,
            'recovery' => 'lease_expiry',
        ];
        return $data;
    }

    private function add_terminal_cleanup_warning_to_error(WP_Error $error, string $cleanup): WP_Error
    {
        if ($this->write_result_succeeded($cleanup) || ! method_exists($error, 'add_data')) {
            return $error;
        }
        $data = method_exists($error, 'get_error_data') ? $error->get_error_data() : [];
        $data = is_array($data) ? $data : [];
        $error->add_data($this->add_terminal_cleanup_warning_to_data($data, $cleanup));
        return $error;
    }
    /**
     * @param array<int,string> $results
     */
    private function required_projection_error(
        int $post_id,
        string $generation,
        array $results
    ): ?WP_Error {
        foreach ($results as $result) {
            if ($this->write_result_succeeded($result)) {
                continue;
            }
            if (self::WRITE_DB_ERROR === $result) {
                return $this->fail_text_generation(
                    $post_id,
                    $generation,
                    $this->write_result_error($result)
                );
            }
            return $this->text_generation_fenced_error();
        }
        return null;
    }

    /**
     * Emit the update hooks that wp_insert_post() emits after its database
     * update. The storage-fenced SQL replaces only the core write itself.
     *
     * Core calls wp_transition_post_status() before the edit and save hooks.
     * That function emits the static transition hook and both dynamic hooks,
     * even when the old and new statuses are equal.
     *
     * @param mixed $post_before
     * @param mixed $post_after
     */
    private function emit_conditional_post_update_hooks(int $post_id, $post_before, $post_after): void
    {
        if (! $post_before || ! $post_after || ! function_exists('do_action')) {
            return;
        }
        $post_type = (string) ($post_after->post_type ?? 'post');
        $old_status = (string) ($post_before->post_status ?? '');
        $new_status = (string) ($post_after->post_status ?? '');

        do_action('transition_post_status', $new_status, $old_status, $post_after);
        do_action($old_status . '_to_' . $new_status, $post_after);
        do_action($new_status . '_' . $post_type, $post_id, $post_after, $old_status);
        do_action('edit_post_' . $post_type, $post_id, $post_after);
        do_action('edit_post', $post_id, $post_after);
        do_action('post_updated', $post_id, $post_after, $post_before);
        do_action('save_post_' . $post_type, $post_id, $post_after, true);
        do_action('save_post', $post_id, $post_after, true);
        do_action('wp_insert_post', $post_id, $post_after, true);
        do_action('wp_after_insert_post', $post_id, $post_after, true, $post_before);
    }

    private function terminal_generation(string $terminal): string
    {
        $lease = $this->decode_option_lease($terminal);
        return (string) ($lease['token'] ?? '');
    }

    private function owns_text_storage_fence(int $post_id, string $generation, string $terminal): string
    {
        global $wpdb;
        if (! isset($wpdb->options, $wpdb->postmeta) || ! is_callable([$wpdb, 'get_var'])) {
            return $this->owns_text_terminal('storage_fence', $post_id, $generation, $terminal)
                ? self::WRITE_OWNED_NO_CHANGE
                : self::WRITE_SUPERSEDED;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- exact post-write ownership probe; no mutation is performed here.
        $owned = $wpdb->get_var($wpdb->prepare(
            "SELECT 1 FROM {$wpdb->options} lease
             INNER JOIN {$wpdb->postmeta} pending
                ON pending.post_id = %d
               AND pending.meta_key = %s AND pending.meta_value = %s
             WHERE lease.option_name = %s AND lease.option_value = %s",
            $post_id,
            '_encypher_provenance_generation_pending',
            $generation,
            'encypher_text_generation_' . $post_id,
            $terminal
        ));
        if ($this->wpdb_query_failed($wpdb, $owned)) {
            return self::WRITE_DB_ERROR;
        }
        return '1' === (string) $owned ? self::WRITE_OWNED_NO_CHANGE : self::WRITE_SUPERSEDED;
    }

    private function invalidate_generation_meta_cache(int $post_id): void
    {
        if (function_exists('wp_cache_delete')) {
            wp_cache_delete($post_id, 'post_meta');
        }
    }

    private function owns_text_terminal(
        string $checkpoint,
        int $post_id,
        string $generation,
        string $terminal_lease
    ): bool {
        $this->text_generation_checkpoint($checkpoint, $post_id, $generation);
        return '' !== $terminal_lease
            && hash_equals(
                $generation,
                (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true)
            )
            && (
                0 === strpos($terminal_lease, 'request-local:')
                || hash_equals(
                    $terminal_lease,
                    (string) get_option('encypher_text_generation_' . $post_id, '')
                )
            );
    }

    private function release_text_terminal(int $post_id, string $terminal_lease): string
    {
        if (0 === strpos($terminal_lease, 'request-local:') || '' === $terminal_lease) {
            return self::WRITE_OWNED_NO_CHANGE;
        }
        $result = self::WRITE_DB_ERROR;
        for ($attempt = 0; $attempt < 3 && self::WRITE_DB_ERROR === $result; $attempt++) {
            $result = $this->compare_and_delete_option_lease(
                'encypher_text_generation_' . $post_id,
                $terminal_lease
            );
        }
        if (! $this->write_result_succeeded($result)) {
            $message = sprintf(
                'Encypher terminal lease cleanup failed for post %d: %s. The lease will expire automatically.',
                $post_id,
                $result
            );
            Logger::log($message);
            if (function_exists('do_action')) {
                do_action('encypher_text_terminal_cleanup_warning', $post_id, $result);
            }
        }
        return $result;
    }
    private function finalize_pending_generation_delete(
        int $post_id,
        string $generation,
        string $terminal_lease,
        string $expected_status,
        bool $committing
    ): string {
        $this->text_generation_checkpoint('finalize_pending', $post_id, $generation);
        if (0 === strpos($terminal_lease, 'request-local:')) {
            $deleted = delete_post_meta(
                $post_id,
                '_encypher_provenance_generation_pending',
                $generation
            );
            return false !== $deleted ? self::WRITE_MUTATED : self::WRITE_OWNED_NO_CHANGE;
        }
        global $wpdb;
        if (
            ! isset($wpdb->options, $wpdb->postmeta)
            || ! is_callable([$wpdb, 'prepare'])
            || ! is_callable([$wpdb, 'query'])
        ) {
            if (! hash_equals(
                $terminal_lease,
                (string) get_option('encypher_text_generation_' . $post_id, '')
            )) {
                return self::WRITE_SUPERSEDED;
            }
            $deleted = delete_post_meta(
                $post_id,
                '_encypher_provenance_generation_pending',
                $generation
            );
            return false !== $deleted ? self::WRITE_MUTATED : self::WRITE_OWNED_NO_CHANGE;
        }
        // The affected row is the ownership proof. Never probe the pending row
        // after deleting it.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
        $deleted = $wpdb->query($wpdb->prepare(
            "DELETE pending FROM {$wpdb->postmeta} pending
             INNER JOIN {$wpdb->options} lease
                ON lease.option_name = %s AND lease.option_value = %s
             WHERE pending.post_id = %d
               AND pending.meta_key = %s AND pending.meta_value = %s",
            'encypher_text_generation_' . $post_id,
            $terminal_lease,
            $post_id,
            '_encypher_provenance_generation_pending',
            $generation
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
        if ($this->wpdb_query_failed($wpdb, $deleted)) {
            return self::WRITE_DB_ERROR;
        }
        if ((int) $deleted > 0) {
            $this->invalidate_generation_meta_cache($post_id);
            return self::WRITE_MUTATED;
        }
        $exact_terminal = hash_equals(
            $terminal_lease,
            (string) get_option('encypher_text_generation_' . $post_id, '')
        );
        $pending_absent = '' === (string) get_post_meta(
            $post_id,
            '_encypher_provenance_generation_pending',
            true
        );
        $status_matches = hash_equals(
            $expected_status,
            (string) get_post_meta($post_id, '_encypher_provenance_status', true)
        );
        $generation_matches = ! $committing || hash_equals(
            $generation,
            (string) get_post_meta($post_id, '_encypher_provenance_generation', true)
        );
        return $exact_terminal && $pending_absent && $status_matches && $generation_matches
            ? self::WRITE_OWNED_NO_CHANGE
            : self::WRITE_SUPERSEDED;
    }

    /**
     * Run one terminal-swap transaction statement when a real wpdb connection
     * is available. Narrow non-WordPress shims are single-request and need no
     * cross-connection visibility fence.
     */
    private function text_terminal_transaction(string $statement): bool
    {
        global $wpdb;
        if (! in_array($statement, ['START TRANSACTION', 'COMMIT', 'ROLLBACK'], true)) {
            return false;
        }
        if (! is_object($wpdb) || ! method_exists($wpdb, 'query')) {
            return true;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- private callers pass only fixed transaction control statements.
        $result = $wpdb->query($statement);
        return ! $this->wpdb_query_failed($wpdb, $result);
    }

    /**
     * Abort a generation and report projection and terminal cleanup separately.
     *
     * @return array{result:string,cleanup:string}
     */
    private function abort_text_generation(int $post_id, string $generation, string $status): array
    {
        $owns_boundary = isset($this->text_projection_terminals[$post_id]);
        $mutex = '';
        if ($owns_boundary) {
            $terminal_lease = $this->text_projection_terminals[$post_id];
        } else {
            $mutex = $this->acquire_text_projection_mutex($post_id);
            if ('' === $mutex) {
                return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
            }
            $terminal_lease = $this->terminalize_text_generation($post_id, $generation);
        }

        try {
            if ('' === $terminal_lease) {
                return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
            }
            if (! $owns_boundary) {
                $this->text_projection_terminals[$post_id] = $terminal_lease;
            }
            foreach (['_encypher_provenance_cached_content_hash', '_encypher_marked_date'] as $key) {
                if (! $this->owns_text_terminal('abort_' . $key, $post_id, $generation, $terminal_lease)) {
                    return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                }
                $deleted = $this->delete_generation_meta($post_id, $key);
                if (! $this->write_result_succeeded($deleted)) {
                    return ['result' => $deleted, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                }
            }
            if (! $this->owns_text_terminal('abort_status', $post_id, $generation, $terminal_lease)) {
                return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
            }
            $status_result = $this->project_generation_meta($post_id, '_encypher_provenance_status', $status);
            if (! $this->write_result_succeeded($status_result)) {
                return ['result' => $status_result, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
            }
            $finalized = $this->finalize_pending_generation_delete(
                $post_id,
                $generation,
                $terminal_lease,
                $status,
                false
            );
            if (! $this->write_result_succeeded($finalized)) {
                return ['result' => $finalized, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
            }
            return [
                'result' => $finalized,
                'cleanup' => $this->release_text_terminal($post_id, $terminal_lease),
            ];
        } finally {
            $this->discard_staged_generation_content($post_id, $generation);
            if (! $owns_boundary) {
                unset($this->text_projection_terminals[$post_id]);
                $this->release_text_projection_mutex($mutex);
            }
        }
    }

    /**
     * Publish a generation and report projection and terminal cleanup separately.
     *
     * @return array{result:string,cleanup:string}
     */
    private function commit_text_generation(int $post_id, string $generation): array
    {
        $terminal_lease = $this->text_projection_terminals[$post_id] ?? '';
        if ('' === $terminal_lease) {
            return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
        }
        if (! $this->owns_text_terminal('commit_generation', $post_id, $generation, $terminal_lease)) {
            return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
        }
        $generation_result = $this->project_generation_meta(
            $post_id,
            '_encypher_provenance_generation',
            $generation
        );
        if (! $this->write_result_succeeded($generation_result)) {
            return ['result' => $generation_result, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
        }
        if (! $this->owns_text_terminal('commit_status', $post_id, $generation, $terminal_lease)) {
            return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
        }
        // Pending makes every public reader fail closed until its exact delete
        // below publishes the whole response projection.
        $status_result = $this->project_generation_meta(
            $post_id,
            '_encypher_provenance_status',
            'c2pa_protected'
        );
        if (! $this->write_result_succeeded($status_result)) {
            return ['result' => $status_result, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
        }
        $staged = $this->staged_generation_contents[$post_id] ?? null;
        $snapshot = $this->staged_generation_snapshots[$post_id] ?? null;
        $has_staged_content = is_array($staged)
            && hash_equals($generation, (string) ($staged['generation'] ?? ''));
        $has_staged_snapshot = is_array($snapshot)
            && hash_equals($generation, (string) ($snapshot['generation'] ?? ''));
        $terminal_swap = $has_staged_content || $has_staged_snapshot;
        $terminal_swap_committed = ! $terminal_swap;
        if ($terminal_swap && ! $this->text_terminal_transaction('START TRANSACTION')) {
            return ['result' => self::WRITE_DB_ERROR, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
        }

        try {
            if ($terminal_swap) {
                $this->text_generation_checkpoint('terminal_swap_started', $post_id, $generation);
            }
            if ($has_staged_snapshot) {
                foreach ($snapshot['meta'] ?? [] as $meta_key => $entry) {
                    if (! $this->owns_text_terminal('commit_snapshot', $post_id, $generation, $terminal_lease)) {
                        return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                    }
                    $snapshot_result = $this->project_generation_meta(
                        $post_id,
                        (string) $meta_key,
                        $entry['value']
                    );
                    if (! $this->write_result_succeeded($snapshot_result)) {
                        return ['result' => $snapshot_result, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                    }
                }
            }
            if ($has_staged_content) {
                if (! $this->owns_text_terminal('commit_content', $post_id, $generation, $terminal_lease)) {
                    return ['result' => self::WRITE_SUPERSEDED, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                }
                $this->is_signing = true;
                $content_result = $this->project_generation_content(
                    $post_id,
                    (string) ($staged['content'] ?? '')
                );
                $this->is_signing = false;
                if (! $this->write_result_succeeded($content_result)) {
                    return ['result' => $content_result, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                }
            }
            $finalized = $this->finalize_pending_generation_delete(
                $post_id,
                $generation,
                $terminal_lease,
                'c2pa_protected',
                true
            );
            if (! $this->write_result_succeeded($finalized)) {
                return ['result' => $finalized, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
            }
            if ($terminal_swap) {
                if (! $this->text_terminal_transaction('COMMIT')) {
                    return ['result' => self::WRITE_DB_ERROR, 'cleanup' => self::WRITE_OWNED_NO_CHANGE];
                }
                $terminal_swap_committed = true;
                $this->text_generation_checkpoint('terminal_swap_committed', $post_id, $generation);
            }
            $this->discard_staged_generation_content($post_id, $generation);
            return [
                'result' => $finalized,
                'cleanup' => $this->release_text_terminal($post_id, $terminal_lease),
            ];
        } finally {
            if ($terminal_swap && ! $terminal_swap_committed) {
                $this->text_terminal_transaction('ROLLBACK');
                $this->invalidate_generation_meta_cache($post_id);
                if (function_exists('clean_post_cache')) {
                    clean_post_cache($post_id);
                }
                // Non-database test shims have no transaction isolation.
                // Compensate only while this exact terminal owner survives.
                if ($this->owns_text_terminal('rollback_terminal_swap', $post_id, $generation, $terminal_lease)) {
                    if ($has_staged_content) {
                        $this->is_signing = true;
                        $this->project_generation_content(
                            $post_id,
                            (string) ($staged['held_content'] ?? '')
                        );
                        $this->is_signing = false;
                    }
                    if ($has_staged_snapshot) {
                        $this->restore_staged_generation_snapshot($post_id, $generation);
                    }
                }
            }
        }
    }

    public function handle_sign_request(WP_REST_Request $request)
    {
        $post_id = (int) $request->get_param('post_id');
        if ($this->is_autosave_or_revision($post_id)) {
            return new WP_Error('invalid_post', __('Autosaves and revisions cannot be signed.', 'encypher-provenance'), ['status' => 400]);
        }
        $post = get_post($post_id);

        if (! $post || 'trash' === $post->post_status) {
            return new WP_Error('invalid_post', __('Invalid post.', 'encypher-provenance'), ['status' => 400]);
        }

        // Protected publication signs a held pending/future generation before
        // it becomes public. Ordinary drafts remain ineligible.
        $assurance = get_post_meta($post_id, '_encypher_publication_assurance', true);
        $held_for_signing = is_array($assurance)
            && in_array((string) ($assurance['state'] ?? ''), ['preparing', 'preparing_update', 'signed_scheduled'], true);
        $post = $this->apply_protected_signing_candidate($post, $assurance);
        if ('publish' !== $post->post_status
            && ! ($held_for_signing && in_array((string) $post->post_status, ['pending', 'future'], true))
        ) {
            return new WP_Error(
                'invalid_post_status',
                __('Only public content or a protected publication candidate can be signed with C2PA.', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        $metadata = $request->get_param('metadata');
        if (! is_array($metadata)) {
            $metadata = [];
        }

        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }
        if (! EnvironmentGuard::signing_allowed($settings)) {
            return new WP_Error(
                'encypher_environment_signing_blocked',
                __('Signing is disabled for this site environment or suspected clone.', 'encypher-provenance'),
                ['status' => 409]
            );
        }
        $settings['install_id'] = $this->ensure_local_install_id($settings);


        $clean_content = $this->strip_c2pa_embeddings($post->post_content);
        if ($clean_content !== $post->post_content) {
            $this->debug_log(sprintf(
                'Post %d contained existing invisible provenance markers. Stripped before re-signing.',
                $post_id
            ));
        }

        $clean_content = $this->sanitize_wp_block_comments($clean_content);

        $verify_clean = $this->detect_c2pa_embeddings($clean_content);
        if ($verify_clean['count'] > 0) {
            return new WP_Error(
                'embedding_strip_failed',
                __('Failed to strip existing C2PA embeddings. Please contact support.', 'encypher-provenance'),
                ['status' => 500]
            );
        }

        $action_type = get_post_meta($post_id, '_encypher_action_type', true);
        if (!$action_type) {
            $is_marked = get_post_meta($post_id, '_encypher_marked', true);
            $action_type = $is_marked ? 'c2pa.edited' : 'c2pa.created';
        }

        $previous_instance_id = null;
        $previous_signed_content = null;
        if ($action_type === 'c2pa.edited') {
            $previous_instance_id = get_post_meta($post_id, '_encypher_provenance_instance_id', true);
            $previous_signed_content = $this->previous_signed_source_text($post_id);
            if ('' === $previous_signed_content) {
                $previous_signed_content = null;
            }
            if ($previous_instance_id) {
                $this->debug_log(sprintf(
                    'Post %d is being edited. Previous instance_id: %s',
                    $post_id,
                    $previous_instance_id
                ));
            }
        }

        $tier = $settings['tier'] ?? 'free';
        $organization_name = ! empty($settings['organization_name']) ? sanitize_text_field((string) $settings['organization_name']) : '';
        $signing_mode = $settings['signing_mode'] ?? 'managed';
        if (! Capabilities::allows(Capabilities::BYOK)) {
            $signing_mode = 'managed';
        }
        $signing_profile_id = ($signing_mode === 'byok' && !empty($settings['signing_profile_id']))
            ? sanitize_text_field((string) $settings['signing_profile_id'])
            : '';


        $extracted_text = $this->extract_text_from_html($clean_content);
        if ('' === trim($extracted_text)) {
            return new WP_Error(
                'empty_content',
                __('No text content found in the post to sign.', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        // Free-tier quota pre-flight: refuse to call the backend when the
        // monthly signing allowance is demonstrably exhausted. Fails open
        // when the usage snapshot is unavailable.
        $quota_error = $this->quota_exhausted_error($settings);
        if ($quota_error) {
            return $quota_error;
        }

        $this->debug_log(sprintf(
            'Post %d extracted %d chars of plain text from %d chars of HTML',
            $post_id,
            strlen($extracted_text),
            strlen($clean_content)
        ));

        $context = [
            'document_title' => $post->post_title,
            'document_url' => get_permalink($post),
            'action' => $action_type,
            'metadata' => [
                'author' => get_the_author_meta('display_name', $post->post_author),
                'published_at' => $post->post_date,
                'wordpress_post_id' => $post_id,
                'tier' => $tier,
                'organization_name' => $organization_name,
                'signing_mode' => $signing_mode,
            ],
        ];
        $editorial_provenance = EditorialProvenance::for_post($post_id);
        if (! empty($editorial_provenance)) {
            $context['editorial_provenance'] = $editorial_provenance;
        }
        $article_metadata = EditorialMetadata::build_assertion_data($post_id);
        if (null !== $article_metadata) {
            $context['article_metadata'] = $article_metadata;
        }


        if ($previous_instance_id) {
            $context['previous_instance_id'] = $previous_instance_id;
            if ($previous_signed_content) {
                $context['previous_signed_content'] = $previous_signed_content;
            }
        }

        if (isset($metadata['license_type'])) {
            $context['license_type'] = sanitize_text_field($metadata['license_type']);
            if (isset($metadata['license_url'])) {
                $context['license_url'] = esc_url_raw($metadata['license_url']);
            }
            if (isset($metadata['license_contact_email'])) {
                $context['license_contact_email'] = sanitize_email($metadata['license_contact_email']);
            }
        }

        if (!empty($metadata['custom_assertions']) && is_array($metadata['custom_assertions'])) {
            $context['custom_assertions'] = $metadata['custom_assertions'];
        }
        if (!empty($metadata['template_id'])) {
            $context['template_id'] = sanitize_text_field($metadata['template_id']);
        }
        if (!empty($metadata['segment_rights']) && is_array($metadata['segment_rights'])) {
            $context['segment_rights'] = $metadata['segment_rights'];
        }

        $hash_only_media_ids = [];
        $allow_full_asset_upload = ! array_key_exists('allow_full_asset_upload', $settings)
            || ! empty($settings['allow_full_asset_upload']);
        // Composite document-level signing: extract media, route to /sign/rich if present.
        $composite_enabled = ($settings['composite_signing_enabled'] ?? true) !== false;
        $media_items = [];
        if ($composite_enabled) {
            $extractor = new MediaBlockExtractor();
            $media_items = $extractor->extract_media_from_content($clean_content, $post_id, !empty($settings['auto_sign_images']));
            if (empty($settings['auto_sign_images'])) {
                $media_items = array_values(array_filter($media_items, static function (array $item): bool {
                    return ($item['source_block'] ?? '') !== 'featured_image';
                }));
            }
            // IDOR guard: drop attachments the acting user cannot edit before
            // they ever reach composite signing (which overwrites the file on
            // disk). See filter_authorized_media_items() for the full rationale.
            $media_items = $this->filter_authorized_media_items($media_items, $post_id);
            if ($allow_full_asset_upload) {
                // Keep the exact bytes sent for signing as the recovery copy.
                foreach ($media_items as &$media_item) {
                    // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- authenticated signing reads the attachment once for upload and failed-write recovery.
                    $original_bytes = @file_get_contents((string) ($media_item['file_path'] ?? ''));
                    if (false !== $original_bytes) {
                        $media_item['_encypher_original_bytes'] = $original_bytes;
                    }
                }
                unset($media_item);
            } else {
                $hash_only_media_ids = array_values(array_unique(array_filter(array_map(
                    static fn(array $item): int => (int) ($item['attachment_id'] ?? 0),
                    $media_items
                ))));
                $media_items = [];
            }
        }
        if (! $allow_full_asset_upload && ! empty($settings['auto_sign_images'])) {
            $featured_id = (int) get_post_thumbnail_id($post_id);
            if ($featured_id > 0 && current_user_can('edit_post', $featured_id)) {
                $hash_only_media_ids[] = $featured_id;
                $hash_only_media_ids = array_values(array_unique($hash_only_media_ids));
            }
        }

        // Collect block-level rights from Gutenberg block attributes and post meta.
        $settings = $this->collect_block_rights($post_id, $clean_content, $media_items, $context, $settings);

        $use_rich = !empty($media_items);
        $attempt = $this->capture_text_attempt($post, $clean_content, $media_items, $context);
        if (null !== $request->get_param('_encypher_generation_token')) {
            return new WP_Error(
                'invalid_generation_token',
                __('Generation tokens are internal and cannot be supplied by REST clients.', 'encypher-provenance'),
                ['status' => 400]
            );
        }
        $generation = $this->deferred_generation_tokens[$post_id] ?? '';
        if ('' !== $generation) {
            if (! $this->owns_text_generation($post_id, $generation)) {
                return $this->text_generation_fenced_error();
            }
        } else {
            $generation = (string) $this->begin_text_generation($post_id);
        }
        if ('' === $generation || ! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        try {
            $inventory_contract = SignRequestBuilder::build_wordpress_inventory_contract(
                $settings,
                $post_id,
                $generation,
                $context['metadata']
            );
        } catch (\InvalidArgumentException $error) {
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error('invalid_signing_identity', $error->getMessage(), ['status' => 500])
            );
        }
        // Production's SignRequestBuilder always returns the complete frozen
        // shape. Preserve compatibility with narrow standalone test doubles.
        $context['document_id'] = (string) ($inventory_contract['document_id'] ?? ($context['document_id'] ?? ''));
        $context['work_id'] = (string) ($inventory_contract['logical_asset_id'] ?? ($context['work_id'] ?? ''));
        $context['metadata'] = is_array($inventory_contract['metadata'] ?? null)
            ? $inventory_contract['metadata']
            : (is_array($context['metadata'] ?? null) ? $context['metadata'] : []);
        $operation_key = (string) ($inventory_contract['operation_key'] ?? $generation);


        if ($use_rich) {
            $this->debug_log(sprintf(
                'Post %d has %d media items - routing to /sign/rich (composite signing)',
                $post_id,
                count($media_items)
            ));

            // The rich endpoint signs `content` exactly; it does not parse
            // HTML before embedding its text markers. Sign the same canonical
            // plain-text stream that WordPress later projects into Gutenberg
            // text nodes, while the media arrays preserve the composite asset.
            $rich_context = $context;
            $rich_context['content_format'] = 'plain';
            $payload = SignRequestBuilder::build_rich_sign_payload(
                $extracted_text,
                $media_items,
                $rich_context,
                $settings
            );
            $response = $this->call_backend('/sign/rich', $payload, true, 120, $operation_key);
            if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
                return $this->text_generation_fenced_error();
            }
            if (is_wp_error($response)) {
                return $this->fail_text_generation($post_id, $generation, $response);
            }

            // Log the API response structure for diagnostics.
            $rdata = $response['data'] ?? [];
            $img_count = count($rdata['images'] ?? []);
            $aud_count = count($rdata['audios'] ?? []);
            $vid_count = count($rdata['videos'] ?? []);
            $composite = $rdata['composite_manifest'] ?? [];
            Logger::log(sprintf(
                'Encypher /sign/rich response: images=%d audios=%d videos=%d composite=%s text_keys=%s',
                $img_count, $aud_count, $vid_count,
                !empty($composite['instance_id']) ? $composite['instance_id'] : 'none',
                implode(',', array_keys($rdata['text'] ?? []))
            ));
            foreach (($rdata['images'] ?? []) as $i => $img) {
                Logger::log(sprintf(
                    '  image[%d]: pos=%s b64_len=%d c2pa=%s',
                    $i, $img['position'] ?? '?', strlen($img['signed_image_b64'] ?? ''),
                    $img['c2pa_manifest_instance_id'] ?? 'none'
                ));
            }
            foreach (($rdata['audios'] ?? []) as $i => $aud) {
                Logger::log(sprintf(
                    '  audio[%d]: pos=%s b64_len=%d c2pa=%s',
                    $i, $aud['position'] ?? '?', strlen($aud['signed_audio_b64'] ?? ''),
                    $aud['c2pa_manifest_instance_id'] ?? 'none'
                ));
            }
            foreach (($rdata['videos'] ?? []) as $i => $vid) {
                Logger::log(sprintf(
                    '  video[%d]: pos=%s b64_len=%d c2pa=%s',
                    $i, $vid['position'] ?? '?', strlen($vid['signed_video_b64'] ?? ''),
                    $vid['c2pa_manifest_instance_id'] ?? 'none'
                ));
            }

            $rich_result = $this->process_rich_sign_response(
                $response,
                $post_id,
                $clean_content,
                $extracted_text,
                $media_items,
                $signing_mode,
                $signing_profile_id,
                $settings,
                $generation,
                $attempt
            );
            if (! is_wp_error($rich_result)) {
                $this->sign_unsigned_post_media($post_id, $clean_content, $settings);
            }
            return $rich_result;
        }

        // Text-only signing via /sign.
        $payload = SignRequestBuilder::build_text_sign_payload($extracted_text, $context, $settings);

        $response = $this->call_backend('/sign', $payload, true, 20, $operation_key);
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (is_wp_error($response)) {
            return $this->fail_text_generation($post_id, $generation, $response);
        }
        $projection_guard = $this->open_text_projection_guard($post_id, $generation, $attempt);
        if (null === $projection_guard) {
            return $this->text_generation_fenced_error();
        }

        // Preserve legacy singular shapes while accepting the unified one-item
        // documents list used by the current text signer.
        $result = $response;
        if (isset($response['data']['documents'][0]) && is_array($response['data']['documents'][0])) {
            $result = $response['data']['documents'][0];
        } elseif (isset($response['data']['document']) && is_array($response['data']['document'])) {
            $result = $response['data']['document'];
        } elseif (isset($response['data']) && is_array($response['data'])) {
            $result = $response['data'];
        }

        // Normalize fields that may be returned at response.data level instead of
        // nested response.data.document in some backend builds.
        if (!isset($result['embedding_plan']) && isset($response['data']['embedding_plan']) && is_array($response['data']['embedding_plan'])) {
            $result['embedding_plan'] = $response['data']['embedding_plan'];
        }
        if (!isset($result['signed_text']) && isset($response['data']['signed_text']) && is_string($response['data']['signed_text'])) {
            $result['signed_text'] = $response['data']['signed_text'];
        }
        if (!isset($result['embedded_content']) && isset($response['data']['embedded_content']) && is_string($response['data']['embedded_content'])) {
            $result['embedded_content'] = $response['data']['embedded_content'];
        }

        // Prefer reconstructing signed text from embedding_plan (format-preserving),
        // then fall back to signed_text/embedded_content for compatibility.
        $signed_text = $this->resolve_signed_text_with_embedding_plan($result, $extracted_text);
        if (! is_string($signed_text) || '' === $signed_text) {
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error('invalid_response', __('Unexpected response from Enterprise API.', 'encypher-provenance'), ['status' => 502])
            );
        }

        $final_check = $this->detect_c2pa_embeddings($signed_text);
        $leaf_pointer_layer = isset($result['leaf_pointer_layer']) && is_array($result['leaf_pointer_layer'])
            ? $result['leaf_pointer_layer']
            : [];
        $pointer_presentation = $this->has_leaf_pointer_presentation($leaf_pointer_layer, $signed_text);
        if ($final_check['count'] < 1 && !$pointer_presentation) {
            $this->debug_log(sprintf(
                'C2PA compliance violation! Expected a wrapper or marked leaf pointer, found neither in post %d',
                $post_id
            ));
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error(
                    'c2pa_compliance_violation',
                    __('C2PA compliance check failed: no manifest wrapper or marked sentence pointer was returned. Please contact support.', 'encypher-provenance'),
                    ['status' => 500]
                )
            );
        }

        // Embed signed text (with invisible markers) back into the original HTML
        $embed_stats = null;
        $embedded_content = $this->embed_signed_text_in_html($clean_content, $signed_text, $embed_stats);

        $embedded_check = $this->detect_c2pa_embeddings($embedded_content);
        $embedded_pointer_presentation = $pointer_presentation
            && $this->has_leaf_pointer_presentation($leaf_pointer_layer, $embedded_content);
        $embed_incomplete = is_array($embed_stats) && empty($embed_stats['complete']);
        if (($embedded_check['count'] < 1 && !$embedded_pointer_presentation) || $embed_incomplete) {
            $this->debug_log(sprintf(
                'C2PA embed persistence failure for post %d! wrappers=%d, leaf_pointer=%d, fragments matched %d/%d (failed_nonempty=%d). Refusing to persist a partial signature.',
                $post_id,
                $embedded_check['count'],
                $embedded_pointer_presentation ? 1 : 0,
                is_array($embed_stats) ? (int) $embed_stats['matched'] : -1,
                is_array($embed_stats) ? (int) $embed_stats['total'] : -1,
                is_array($embed_stats) ? (int) $embed_stats['failed_nonempty'] : -1
            ));
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error(
                    'c2pa_embed_persist_failed',
                    __('C2PA embedding could not be fully persisted into the WordPress post content. Please contact support.', 'encypher-provenance'),
                    ['status' => 500]
                )
            );
        }

        $this->debug_log(sprintf(
            'Post %d successfully signed with durable provenance carriers. HTML: %d chars, signed text: %d chars',
            $post_id,
            strlen($embedded_content),
            strlen($signed_text)
        ));

        $document_id = isset($result['document_id']) ? sanitize_text_field((string) $result['document_id']) : '';
        $total_sentences = 0;
        if (isset($result['total_segments'])) {
            $total_sentences = (int) $result['total_segments'];
        } elseif (isset($result['total_sentences'])) {
            $total_sentences = (int) $result['total_sentences'];
        }

        $instance_id = '';
        if (!empty($result['instance_id'])) {
            $instance_id = sanitize_text_field((string) $result['instance_id']);
        } elseif (!empty($result['metadata']['instance_id'])) {
            $instance_id = sanitize_text_field((string) $result['metadata']['instance_id']);
        }
        $verification_id = $instance_id ?: $document_id;
        $verify_slug = Plugin::get_verify_slug();
        $verification_url = $verification_id ? home_url('/' . $verify_slug . '/') . urlencode($verification_id) : '';

        // WS1: editor-clean, render-time embedding.
        // Store the CLEAN (marker-free) content in post_content so the editor,
        // copy/paste, diffs, and other plugins never see invisible bytes. The
        // marker-bearing signed text is kept in a side store and injected at
        // render time (Frontend::inject_provenance_markers on `the_content`).
        // The content-hash guard is computed over the WS0 canonical visible
        // text, NOT over the embedded bytes, so autosave/REST re-saves that do
        // not change visible text never trigger a phantom re-sign.
        $canonical_hash = $this->get_parser()->canonical_text_hash($clean_content);
        // The generation was acquired before the outbound signing call and is
        // still bound to the exact input snapshot captured above.
        if (! $this->owns_text_mutation('content', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $this->is_signing = true;
        $updated = $this->project_or_stage_generation_content($post_id, $generation, $clean_content);
        $this->is_signing = false;

        if (! $this->write_result_succeeded($updated)) {
            return self::WRITE_DB_ERROR === $updated
                ? $this->fail_text_generation($post_id, $generation, $this->write_result_error($updated))
                : $this->text_generation_fenced_error();
        }
        $post_after_update = function_exists(__NAMESPACE__ . '\\get_post') || function_exists('get_post')
            ? get_post($post_id)
            : null;
        if ($post_after_update) {
            $attempt['revision'] = (string) ($post_after_update->post_modified_gmt ?? ($post_after_update->post_modified ?? ''));
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        if (! $this->owns_text_mutation('signed_snapshot', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $projection_error = $this->required_projection_error($post_id, $generation, [
            $this->project_generation_meta($post_id, '_encypher_marked', true),
        ]);
        if (null !== $projection_error) {
            return $projection_error;
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        // Extract instance_id from result for provenance chain
        $new_instance_id = $instance_id ?: null;

        // Persist every remaining field while the generation stays non-public.
        if (! $this->owns_text_mutation('public_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $public_projection_results = [
            $this->project_generation_meta($post_id, '_encypher_provenance_canonical_hash', $canonical_hash),
            $this->project_generation_meta($post_id, '_encypher_provenance_cached_content_hash', md5((string) $clean_content)),
            $this->project_generation_meta($post_id, '_encypher_provenance_document_id', $document_id),
            $this->project_generation_meta($post_id, '_encypher_provenance_verification_url', $verification_url),
            $this->project_generation_meta($post_id, '_encypher_provenance_total_sentences', $total_sentences),
            $this->project_generation_meta($post_id, '_encypher_provenance_last_signed', current_time('mysql')),
            $this->project_generation_meta($post_id, '_encypher_manifest_id', $document_id),
            $this->project_generation_meta($post_id, '_encypher_marked_date', current_time('mysql')),
            $this->project_generation_meta($post_id, '_encypher_provenance_signing_mode', $signing_mode),
        ];
        if ($instance_id) {
            $public_projection_results[] = $this->project_generation_meta(
                $post_id,
                '_encypher_provenance_instance_id',
                $instance_id
            );
        }
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            $public_projection_results
        );
        if (null !== $projection_error) {
            return $projection_error;
        }
        if ($instance_id) {
            // The projection now owns the new instance id. Advance the fence
            // from the captured parent to this exact response generation.
            $attempt['previous_instance_id'] = $instance_id;
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('rights_evidence', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $rights_projection = $this->project_post_rights_evidence($post_id, $result);
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            [$rights_projection['result']]
        );
        if (null !== $projection_error) {
            return $projection_error;
        }
        $rights_evidence = $rights_projection['evidence'];
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('public_ids', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        // WS2: append to the cryptographic edit history (C2PA action chain).
        // The action_type / previous_instance_id were resolved earlier from the
        // post's prior signing state, giving a created -> edited ingredient chain.
        if (! $this->owns_text_mutation('edit_log', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $edit_log_id = EditLog::append([
            'post_id'          => $post_id,
            'action'           => $action_type,
            'content_hash'     => $canonical_hash,
            'manifest_id'      => $document_id,
            'prev_manifest_id' => $previous_instance_id ?: null,
            'instance_id'      => $instance_id ?: null,
        ]);
        if ($edit_log_id <= 0) {
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error(
                    'edit_log_write_failed',
                    __('WordPress could not persist the signing history.', 'encypher-provenance'),
                    ['status' => 500]
                )
            );
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('site_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $settings_after_sign = get_option('encypher_provenance_settings', []);
        if (is_array($settings_after_sign) && empty($settings_after_sign['first_signed_at'])) {
            // Funnel telemetry: stamp the first successful sign once. The
            // merge-sanitizer preserves this key across settings-form saves.
            $settings_after_sign['first_signed_at'] = gmdate('c');
            update_option('encypher_provenance_settings', $settings_after_sign);
        }
        if (is_array($settings_after_sign)) {
            $sign_api_base_url = isset($settings_after_sign['api_base_url']) ? (string) $settings_after_sign['api_base_url'] : '';
            $sign_api_key = isset($settings_after_sign['api_key']) ? (string) $settings_after_sign['api_key'] : '';
            $signed_count = count(get_posts([
                'post_type' => 'any',
                'post_status' => 'any',
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin/cron status-sync count of signed posts by the marker flag; not a front-end query.
                'meta_key' => '_encypher_marked',
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- admin/cron status-sync count of signed posts by the marker flag; not a front-end query.
                'meta_value' => true,
                'fields' => 'ids',
                'posts_per_page' => -1,
                // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- deterministic status-sync count; deliberately bypasses query filters.
                'suppress_filters' => true,
            ]));
            $this->sync_remote_wordpress_status($sign_api_base_url, $sign_api_key, [
                'connection_status' => 'connected',
                'site_url' => home_url('/'),
                'admin_url' => admin_url('admin.php?page=encypher'),
                'site_name' => get_bloginfo('name'),
                'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : 'production',
                'network_id' => is_multisite() ? (string) get_current_network_id() : null,
                'blog_id' => is_multisite() ? get_current_blog_id() : null,
                'is_multisite' => is_multisite(),
                'is_primary' => is_multisite() ? (get_current_blog_id() === get_main_site_id()) : true,
                'organization_id' => isset($settings_after_sign['organization_id']) ? (string) $settings_after_sign['organization_id'] : '',
                'organization_name' => isset($settings_after_sign['organization_name']) ? (string) $settings_after_sign['organization_name'] : '',
                'plugin_version' => ENCYPHER_PROVENANCE_VERSION,
                'plugin_installed' => true,
                'connection_tested' => true,
                'last_connection_checked_at' => gmdate('c'),
                'last_signed_at' => gmdate('c'),
                'last_signed_post_id' => $post_id,
                'last_signed_post_url' => get_permalink($post_id),
                'signed_post_count' => $signed_count,
            ]);
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('profile_instance_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $profile_result = $signing_profile_id
            ? $this->project_generation_meta(
                $post_id,
                '_encypher_provenance_signing_profile_id',
                $signing_profile_id
            )
            : $this->delete_generation_meta($post_id, '_encypher_provenance_signing_profile_id');
        $profile_results = [$profile_result];
        if ($new_instance_id) {
            $profile_results[] = $this->project_generation_meta(
                $post_id,
                '_encypher_provenance_instance_id',
                $new_instance_id
            );
        }
        $profile_results[] = $this->delete_generation_meta($post_id, '_encypher_action_type');
        $projection_error = $this->required_projection_error($post_id, $generation, $profile_results);
        if (null !== $projection_error) {
            return $projection_error;
        }

        $response_embeddings = [];
        if (isset($result['embeddings']) && is_array($result['embeddings'])) {
            $response_embeddings = $result['embeddings'];
        } elseif (isset($response['data']['embeddings']) && is_array($response['data']['embeddings'])) {
            $response_embeddings = $response['data']['embeddings'];
        } elseif (isset($response['embeddings']) && is_array($response['embeddings'])) {
            $response_embeddings = $response['embeddings'];
        }

        if (! $this->owns_text_mutation('sentence_segments', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $sentence_result = $this->persist_sentence_segments($post_id, $response_embeddings);
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            [$sentence_result]
        );
        if (null !== $projection_error) {
            return $projection_error;
        }

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('error_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $projection_error = $this->required_projection_error($post_id, $generation, [
            $this->delete_generation_meta($post_id, '_encypher_needs_marking'),
            $this->delete_generation_meta($post_id, '_encypher_action_type'),
            $this->delete_generation_meta($post_id, '_encypher_provenance_verification'),
            $this->delete_generation_meta($post_id, '_encypher_consecutive_failures'),
        ]);
        if (null !== $projection_error) {
            return $projection_error;
        }

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('featured_image', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        // Sign featured image if auto_sign_images is enabled (text-only path)
        $image_settings = get_option('encypher_provenance_settings', []);
        if ($allow_full_asset_upload && !empty($image_settings['auto_sign_images'])) {
            $this->sign_featured_image($post_id);
        }
        $this->sign_attachments_in_waves($hash_only_media_ids);

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        // Surface features_gated from API response for upsell
        $features_gated = $response['meta']['features_gated'] ?? [];

        if (! $this->owns_text_mutation('usage', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $settings_for_usage = get_option('encypher_provenance_settings', []);
        $updated_usage = $this->resolve_usage_snapshot($settings_for_usage, true);

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $response_data = [
            'status' => 'c2pa_protected',
            'document_id' => $document_id,
            'verification_url' => $verification_url,
            'embedded_content' => $embedded_content,
            'total_sentences' => $total_sentences,
            'usage' => $updated_usage,
            'instance_id' => $instance_id,
        ];
        if (null !== $rights_evidence) {
            $response_data = array_merge($response_data, $rights_evidence);
        }

        if (!empty($features_gated)) {
            $response_data['features_gated'] = $features_gated;
        }

        if (! $this->owns_text_mutation('signed_snapshot', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            $this->project_or_stage_signed_snapshot(
                $post_id,
                $generation,
                $embedded_content,
                $signed_text
            )
        );
        if (null !== $projection_error) {
            return $projection_error;
        }
        if (! $this->has_staged_generation_snapshot($post_id, $generation)) {
            $attempt['previous_signed_content_hash'] = hash('sha256', $signed_text);
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        if (! $this->owns_text_mutation('commit', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $commit = $this->commit_text_generation($post_id, $generation);
        if (! $this->write_result_succeeded($commit['result'])) {
            return $this->write_result_error($commit['result']);
        }
        $response_data = $this->add_terminal_cleanup_warning_to_data($response_data, $commit['cleanup']);
        ErrorLog::record_success($post_id);

        $this->sign_unsigned_post_media($post_id, $clean_content, $settings);

        return new WP_REST_Response($response_data);
    }

    /**
     * Remove every public success signal after an unrecoverable media write.
     */
    private static function invalidate_attachment_provenance(int $attachment_id): void
    {
        foreach ([
            '_encypher_c2pa_instance_id',
            '_encypher_image_manifest_url',
            '_encypher_image_record_id',
            '_encypher_signed_hash',
            '_encypher_signed_at',
            '_encypher_signed_file_hash',
            '_encypher_signed_generation',
            '_encypher_signed_as_part_of',
            '_encypher_signed_action',
            '_encypher_signed_rights_evidence',
            '_encypher_source_has_c2pa',
            '_encypher_source_c2pa_valid',
        ] as $meta_key) {
            delete_post_meta($attachment_id, $meta_key);
        }
        update_post_meta($attachment_id, '_encypher_provenance_status', 'media_signing_failed');
    }
    /**
     * Whether a complete signed attachment commit is current. When a
     * generation is supplied, the attachment must belong to that exact rich
     * request. A separate winner is valid for the attachment, but it cannot
     * complete this parent generation.
     */
    private static function attachment_has_current_signed_commit(
        int $attachment_id,
        string $generation = ''
    ): bool {
        $file_path = get_attached_file($attachment_id);
        $stored_hash = strtolower(trim((string) get_post_meta($attachment_id, '_encypher_signed_file_hash', true)));
        if (
            ! is_string($file_path)
            || '' === $file_path
            || ! is_file($file_path)
            || 1 !== preg_match('/\Asha256:[0-9a-f]{64}\z/', $stored_hash)
            || 'signed' !== (string) get_post_meta($attachment_id, '_encypher_provenance_status', true)
            || '' === (string) get_post_meta($attachment_id, '_encypher_c2pa_instance_id', true)
            || '' === (string) get_post_meta($attachment_id, '_encypher_signed_hash', true)
            || '' === (string) get_post_meta($attachment_id, '_encypher_signed_at', true)
            || (
                '' !== $generation
                && ! hash_equals(
                    $generation,
                    (string) get_post_meta($attachment_id, '_encypher_signed_generation', true)
                )
            )
        ) {
            return false;
        }

        // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local attachment bytes are checked before preserving a concurrent commit.
        $current_bytes = @file_get_contents($file_path);
        return false !== $current_bytes
            && hash_equals($stored_hash, 'sha256:' . hash('sha256', $current_bytes));
    }


    /**
     * Reject active-content payloads and recognized cross-media substitutions
     * before signed bytes replace an attachment.
     */
    private static function signed_bytes_match_asset_type(string $bytes, string $asset_type): bool
    {
        $prefix = ltrim(substr($bytes, 0, 512));
        if (1 === preg_match('/\A(?:<!doctype\s+html|<html\b|<script\b|<svg\b|<\?php|<\?xml\b)/i', $prefix)) {
            return false;
        }
        if (! class_exists('finfo') || ! defined('FILEINFO_MIME_TYPE')) {
            return false;
        }
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
        $detected = strtolower((string) $finfo->buffer($bytes));
        if ('' === $detected) {
            return false;
        }
        if (str_starts_with($detected, 'image/')) {
            return 'image' === $asset_type && 'image/svg+xml' !== $detected;
        }
        if (str_starts_with($detected, 'audio/')) {
            return 'audio' === $asset_type;
        }
        if (str_starts_with($detected, 'video/')) {
            return 'video' === $asset_type;
        }
        if ('application/pdf' === $detected) {
            return 'application' === $asset_type;
        }
        if ('application/octet-stream' === $detected) {
            return self::signed_bytes_magic_asset_type($bytes) === $asset_type;
        }
        return false;
    }

    /**
     * Identify opaque C2PA containers by their source format's magic bytes.
     */
    private static function signed_bytes_magic_asset_type(string $bytes): string
    {
        if (
            str_starts_with($bytes, "\xFF\xD8\xFF")
            || str_starts_with($bytes, "\x89PNG\x0D\x0A\x1A\x0A")
            || str_starts_with($bytes, 'GIF87a')
            || str_starts_with($bytes, 'GIF89a')
            || str_starts_with($bytes, "II\x2A\x00")
            || str_starts_with($bytes, "MM\x00\x2A")
            || str_starts_with($bytes, "\xFF\x0A")
            || str_starts_with($bytes, "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A")
            || (str_starts_with($bytes, 'RIFF') && 'WEBP' === substr($bytes, 8, 4))
        ) {
            return 'image';
        }
        if (
            str_starts_with($bytes, 'ID3')
            || (isset($bytes[1]) && "\xFF" === $bytes[0] && 0xE0 === (ord($bytes[1]) & 0xE0))
            || (str_starts_with($bytes, 'RIFF') && 'WAVE' === substr($bytes, 8, 4))
            || (str_starts_with($bytes, 'FORM') && in_array(substr($bytes, 8, 4), ['AIFF', 'AIFC'], true))
        ) {
            return 'audio';
        }
        if (str_starts_with($bytes, '%PDF-')) {
            return 'application';
        }
        if (strlen($bytes) >= 12 && 'ftyp' === substr($bytes, 4, 4)) {
            $brand = substr($bytes, 8, 4);
            if (in_array($brand, ['avif', 'avis', 'heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1', 'msf1'], true)) {
                return 'image';
            }
            if (in_array($brand, ['M4A ', 'M4B ', 'F4A '], true)) {
                return 'audio';
            }
            if (in_array($brand, ['isom', 'iso2', 'mp41', 'mp42', 'avc1', 'qt  ', 'M4V ', 'F4V '], true)) {
                return 'video';
            }
        }
        return '';
    }

    /**
     * Decode and authenticate signed media evidence before any file write.
     *
     * @return string|WP_Error Validated signed bytes or a fail-closed error.
     */
    private static function validate_signed_media_evidence(
        $signed_b64,
        string $signed_hash,
        string $instance_id,
        string $asset_type,
        bool $c2pa_signed
    ) {
        $signed_hash = strtolower(trim($signed_hash));
        $instance_id = trim($instance_id);
        if (! $c2pa_signed || ! is_string($signed_b64) || '' === $signed_b64 || '' === $instance_id) {
            return new WP_Error(
                'invalid_media_response',
                __('The signing response omitted signed media evidence.', 'encypher-provenance')
            );
        }

        $signed_bytes = base64_decode($signed_b64, true);
        if (
            false === $signed_bytes
            || '' === $signed_bytes
            || 1 !== preg_match('/\Asha256:([0-9a-f]{64})\z/', $signed_hash, $hash_match)
            || ! hash_equals($hash_match[1], hash('sha256', $signed_bytes))
            || ! self::signed_bytes_match_asset_type($signed_bytes, $asset_type)
        ) {
            return new WP_Error(
                'invalid_media_response',
                __('The signing response contained invalid signed media evidence.', 'encypher-provenance')
            );
        }

        return $signed_bytes;
    }
    /**
     * Cryptographically verify the exact signed bytes before replacing the
     * attachment. The verifier's manifest instance must match the signer result.
     *
     * @return true|WP_Error
     */
    protected function verify_signed_media_before_write(
        string $signed_bytes,
        string $mime_type,
        string $filename,
        string $instance_id
    ) {
        $verification = $this->verify_media_bytes($signed_bytes, $mime_type, $filename);
        if (is_wp_error($verification)) {

            return new WP_Error(
                'media_verification_failed',
                sprintf(
                    /* translators: %s: media verification error message. */
                    __('Signed media could not be verified before it was saved: %s', 'encypher-provenance'),
                    $verification->get_error_message()
                )
            );
        }
        if (
            empty($verification['detected'])
            || empty($verification['valid'])
            || '' === (string) ($verification['instance_id'] ?? '')
            || ! hash_equals($instance_id, (string) $verification['instance_id'])
        ) {
            return new WP_Error(
                'invalid_media_response',
                __('The signing response did not contain a valid matching C2PA manifest.', 'encypher-provenance')
            );
        }
        return true;
    }

    /**
     * @param array<string,mixed> $lease
     */
    private function encode_option_lease(array $lease): string
    {
        $encoded = function_exists('wp_json_encode')
            ? wp_json_encode($lease)
            : json_encode($lease); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress always provides wp_json_encode().
        return is_string($encoded) ? $encoded : '';
    }

    /**
     * @return array<string,mixed>
     */
    private function decode_option_lease($raw): array
    {
        if (! is_string($raw) || '' === $raw) {
            return [];
        }
        $lease = json_decode($raw, true);
        return is_array($lease) ? $lease : [];
    }

    /**
     * Atomically replace an exact JSON option value. WordPress does not expose
     * a compare-and-swap option API, so use one conditional SQL UPDATE. The
     * fallback exists only for stripped-down standalone environments.
     */
    private function compare_and_swap_option_lease(
        string $key,
        string $expected,
        string $replacement
    ): bool {
        global $wpdb;

        if (isset($wpdb) && isset($wpdb->options) && is_callable([$wpdb, 'update'])) {
            $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- exact option-value CAS is unavailable through the WordPress options API; the option cache is invalidated below after a successful swap.
                $wpdb->options,
                ['option_value' => $replacement],
                ['option_name' => $key, 'option_value' => $expected],
                ['%s'],
                ['%s', '%s']
            );
            if (1 === $updated && function_exists('wp_cache_delete')) {
                wp_cache_delete($key, 'options');
            }
            return 1 === $updated;
        }

        if (get_option($key, '') !== $expected) {
            return false;
        }
        update_option($key, $replacement, false);
        return get_option($key, '') === $replacement;
    }

    /**
     * Delete only the exact JSON lease value observed by this owner.
     *
     * @return string One of the WRITE_* results.
     */
    private function compare_and_delete_option_lease(string $key, string $lease): string
    {
        global $wpdb;

        if (isset($wpdb) && isset($wpdb->options) && is_callable([$wpdb, 'delete'])) {
            $deleted = $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- exact option-value delete is unavailable through the WordPress options API; the option cache is invalidated below after a successful delete.
                $wpdb->options,
                ['option_name' => $key, 'option_value' => $lease],
                ['%s', '%s']
            );
            if ($this->wpdb_query_failed($wpdb, $deleted)) {
                return self::WRITE_DB_ERROR;
            }
            if (1 === $deleted) {
                if (function_exists('wp_cache_delete')) {
                    wp_cache_delete($key, 'options');
                }
                return self::WRITE_MUTATED;
            }
            $current = get_option($key, '');
            if ('' === $current || false === $current) {
                return self::WRITE_OWNED_NO_CHANGE;
            }
            return is_string($current) && hash_equals($lease, $current)
                ? self::WRITE_DB_ERROR
                : self::WRITE_SUPERSEDED;
        }

        $current = get_option($key, '');
        if ('' === $current || false === $current) {
            return self::WRITE_OWNED_NO_CHANGE;
        }
        if (! is_string($current) || ! hash_equals($lease, $current)) {
            return self::WRITE_SUPERSEDED;
        }
        $deleted = delete_option($key);
        $after_delete = get_option($key, '');
        if ('' === $after_delete || false === $after_delete) {
            return false !== $deleted ? self::WRITE_MUTATED : self::WRITE_OWNED_NO_CHANGE;
        }
        return self::WRITE_DB_ERROR;
    }

    private function option_lease_is_expired(array $lease, int $now): bool
    {
        $expires_at = (int) ($lease['expires_at'] ?? 0);
        if (0 === $expires_at) {
            $expires_at = (int) ($lease['started_at'] ?? 0) + SigningStatus::STALE_AFTER_SECONDS;
        }
        return $expires_at <= $now;
    }

    private function acquire_option_lease(string $key, string $seed, string $phase = 'active'): ?string
    {
        $token = hash('sha256', $seed . '|' . microtime(true) . '|' . uniqid('', true));
        if (! function_exists(__NAMESPACE__ . '\\add_option') && ! function_exists('add_option')) {
            return 'request-local:' . $token;
        }
        $now = time();
        $lease = $this->encode_option_lease([
            'token' => $token,
            'phase' => $phase,
            'started_at' => $now,
            'expires_at' => $now + SigningStatus::STALE_AFTER_SECONDS,
        ]);
        if (add_option($key, $lease, '', false)) {
            return $token;
        }
        $existing = get_option($key, '');
        $existing_lease = $this->decode_option_lease($existing);
        if (
            is_string($existing)
            && (
                '' === $existing
                || $this->option_lease_is_expired($existing_lease, $now)
            )
            && $this->compare_and_swap_option_lease($key, $existing, $lease)
        ) {
            return $token;
        }
        return null;
    }

    private function supersede_option_lease(string $key, string $seed, string $phase = 'active'): ?string
    {
        if (! function_exists(__NAMESPACE__ . '\\add_option') && ! function_exists('add_option')) {
            return 'request-local:' . hash('sha256', $seed . '|' . microtime(true) . '|' . uniqid('', true));
        }
        $token = hash('sha256', $seed . '|' . microtime(true) . '|' . uniqid('', true));
        $now = time();
        $replacement = $this->encode_option_lease([
            'token' => $token,
            'phase' => $phase,
            'started_at' => $now,
            'expires_at' => $now + SigningStatus::STALE_AFTER_SECONDS,
        ]);
        for ($attempt = 0; $attempt < 3; ++$attempt) {
            $existing = get_option($key, '');
            $existing_lease = $this->decode_option_lease($existing);
            $existing_phase = (string) ($existing_lease['phase'] ?? 'active');
            if (
                is_string($existing)
                && '' !== $existing
                && in_array($existing_phase, ['initializing', 'terminalizing'], true)
                && ! $this->option_lease_is_expired($existing_lease, $now)
            ) {
                return null;
            }
            if (is_string($existing) && '' !== $existing && $this->compare_and_swap_option_lease($key, $existing, $replacement)) {
                return $token;
            }
            if (add_option($key, $replacement, '', false)) {
                return $token;
            }
        }
        return null;
    }

    private function owns_option_lease_phase(string $key, string $token, string $phase): bool
    {
        if (0 === strpos($token, 'request-local:')) {
            return true;
        }
        $lease = $this->decode_option_lease(get_option($key, ''));
        return ! empty($lease)
            && ! $this->option_lease_is_expired($lease, time())
            && hash_equals($token, (string) ($lease['token'] ?? ''))
            && $phase === (string) ($lease['phase'] ?? 'active');
    }

    /**
     * CAS one exact owned phase to another.
     *
     * @return string Exact replacement JSON, or an empty string.
     */
    private function transition_option_lease_phase(
        string $key,
        string $token,
        string $expected_phase,
        string $replacement_phase,
        bool $renew_expiry = false
    ): string {
        if (0 === strpos($token, 'request-local:')) {
            return $token . ':' . $replacement_phase;
        }
        $raw = get_option($key, '');
        $lease = $this->decode_option_lease($raw);
        if (
            ! is_string($raw)
            || ''
            === $raw
            || $this->option_lease_is_expired($lease, time())
            || ! hash_equals($token, (string) ($lease['token'] ?? ''))
            || $expected_phase !== (string) ($lease['phase'] ?? 'active')
        ) {
            return '';
        }
        $lease['phase'] = $replacement_phase;
        $lease['phase_id'] = hash('sha256', $token . '|' . $replacement_phase . '|' . microtime(true) . '|' . uniqid('', true));
        if ($renew_expiry) {
            $lease['expires_at'] = time() + SigningStatus::STALE_AFTER_SECONDS;
        }
        $replacement = $this->encode_option_lease($lease);
        return '' !== $replacement && $this->compare_and_swap_option_lease($key, $raw, $replacement)
            ? $replacement
            : '';
    }
    private function owns_option_lease(string $key, string $token): bool
    {
        if (0 === strpos($token, 'request-local:')) {
            return true;
        }
        $lease = $this->decode_option_lease(get_option($key, ''));
        return ! empty($lease)
            && ! $this->option_lease_is_expired($lease, time())
            && hash_equals($token, (string) ($lease['token'] ?? ''));
    }

    private function release_option_lease(string $key, string $token): void
    {
        if (
            0 === strpos($token, 'request-local:')
            || (! function_exists(__NAMESPACE__ . '\\delete_option') && ! function_exists('delete_option'))
        ) {
            return;
        }
        $raw_lease = get_option($key, '');
        $lease = $this->decode_option_lease($raw_lease);
        if (is_string($raw_lease) && hash_equals($token, (string) ($lease['token'] ?? ''))) {
            $this->compare_and_delete_option_lease($key, $raw_lease);
        }
    }

    private function acquire_attachment_commit_lock(int $attachment_id): ?string
    {
        return $this->acquire_option_lease(
            'encypher_attachment_commit_' . $attachment_id,
            (string) $attachment_id
        );
    }

    private function owns_attachment_commit_lock(int $attachment_id, string $token): bool
    {
        return $this->owns_option_lease('encypher_attachment_commit_' . $attachment_id, $token);
    }

    private function renew_attachment_commit_lock(int $attachment_id, string $token, int $now): bool
    {
        if (0 === strpos($token, 'request-local:')) {
            return true;
        }
        $key = 'encypher_attachment_commit_' . $attachment_id;
        $raw = get_option($key, '');
        $lease = $this->decode_option_lease($raw);
        if (
            ! is_string($raw)
            || ! hash_equals($token, (string) ($lease['token'] ?? ''))
            || $this->option_lease_is_expired($lease, $now)
        ) {
            return false;
        }
        $lease['expires_at'] = $now + SigningStatus::STALE_AFTER_SECONDS;
        $renewed = $this->encode_option_lease($lease);
        return '' !== $renewed && $this->compare_and_swap_option_lease($key, $raw, $renewed);
    }

    private function attachment_commit_clock(): int
    {
        return (int) apply_filters('encypher_attachment_commit_clock', time());
    }

    private function heartbeat_attachment_commit(
        int $attachment_id,
        string $token,
        ?callable $worker_heartbeat,
        int &$next_renewal
    ): bool {
        if (
            (null !== $worker_heartbeat && ! $worker_heartbeat())
            || ! $this->owns_attachment_commit_lock($attachment_id, $token)
        ) {
            return false;
        }
        $now = $this->attachment_commit_clock();
        if (0 === $next_renewal) {
            $next_renewal = $now + self::ATTACHMENT_COMMIT_RENEW_SECONDS;
            return true;
        }
        if ($now < $next_renewal) {
            return true;
        }
        if (! $this->renew_attachment_commit_lock($attachment_id, $token, $now)) {
            return false;
        }
        $next_renewal = $now + self::ATTACHMENT_COMMIT_RENEW_SECONDS;
        return true;
    }

    private function release_attachment_commit_lock(int $attachment_id, string $token): void
    {
        $this->release_option_lease('encypher_attachment_commit_' . $attachment_id, $token);
    }

    private function attachment_commit_checkpoint(string $checkpoint, int $attachment_id): void
    {
        if (function_exists(__NAMESPACE__ . '\\do_action') || function_exists('do_action')) {
            do_action('encypher_attachment_commit_checkpoint', $checkpoint, $attachment_id);
        }
    }


    /**
     * Stop a fenced writer without touching a takeover winner. If this token
     * merely expired and nobody replaced it, restore the bytes under the same
     * inode-locked MediaWriter path and clear partial success metadata.
     *
     * @return array{written:bool,error:?WP_Error,disposition:string}
     */
    private function fenced_attachment_commit(
        int $attachment_id,
        string $token,
        string $signed_bytes,
        string $original_bytes
    ): array {
        $key = 'encypher_attachment_commit_' . $attachment_id;
        $raw_lease = get_option($key, '');
        $lease = $this->decode_option_lease($raw_lease);
        if (
            is_string($raw_lease)
            && '' !== $raw_lease
            && hash_equals($token, (string) ($lease['token'] ?? ''))
            && $this->option_lease_is_expired($lease, time())
        ) {
            // Reclaim the exact expired value before recovery. This closes the
            // gap in which a takeover could begin between restoring bytes and
            // purging the restored public URL.
            $now = time();
            $recovery_lease = $this->encode_option_lease([
                'token' => $token,
                'started_at' => $now,
                'expires_at' => $now + SigningStatus::STALE_AFTER_SECONDS,
            ]);
            if (! $this->compare_and_swap_option_lease($key, $raw_lease, $recovery_lease)) {
                return [
                    'written' => false,
                    'error' => null,
                    'disposition' => 'takeover_lost',
                ];
            }
            $restored = MediaWriter::write($attachment_id, $original_bytes, $signed_bytes);
            if (! $this->owns_attachment_commit_lock($attachment_id, $token)) {
                return [
                    'written' => false,
                    'error' => null,
                    'disposition' => 'takeover_lost',
                ];
            }
            if (is_wp_error($restored)) {
                delete_post_meta($attachment_id, '_encypher_public_delivery_state');
                self::invalidate_attachment_provenance($attachment_id);
                $restore_error = new WP_Error(
                    'restore_failed',
                    __('The original attachment could not be restored after the signing lease expired.', 'encypher-provenance')
                );
                update_post_meta($attachment_id, '_encypher_last_sign_error', [
                    'code' => 'restore_failed',
                    'message' => $restore_error->get_error_message(),
                    'time' => time(),
                ]);
                return [
                    'written' => false,
                    'error' => $restore_error,
                    'disposition' => 'restore_failed',
                ];
            }
            // The signed bytes may already have been accepted by a VIP edge.
            // Purge after restoring the unsigned source, then invalidate only
            // while this recovery generation still owns the lease.
            VipMediaDelivery::purge_after_verified_write($attachment_id);
            if (! $this->owns_attachment_commit_lock($attachment_id, $token)) {
                return [
                    'written' => false,
                    'error' => null,
                    'disposition' => 'takeover_lost',
                ];
            }
            delete_post_meta($attachment_id, '_encypher_public_delivery_state');
            self::invalidate_attachment_provenance($attachment_id);
            return [
                'written' => false,
                'error' => null,
                'disposition' => 'expired_restored',
            ];
        }
        return [
            'written' => false,
            'error' => null,
            'disposition' => 'takeover_lost',
        ];
    }

    /**
     * Own the attachment write, fail-closed recovery, success metadata, and
     * public-delivery purge state machine for every media-signing path.
     *
     * @param array<string,mixed> $success_meta Sanitized attachment metadata.
     * @param array<string,mixed>|null $rights_result Signing response whose validated rights evidence belongs to this generation.
     * @return array{written:bool,error:?WP_Error,disposition:string}
     */
    private function write_and_commit_signed_attachment(
        int $attachment_id,
        string $signed_bytes,
        string $original_bytes,
        array $success_meta,
        ?array $rights_result = null
    ): array {
        $lock_token = $this->acquire_attachment_commit_lock($attachment_id);
        if (null === $lock_token) {
            return [
                'written' => false,
                'error' => new WP_Error(
                    'source_changed',
                    __('A newer attachment signing commit is in progress.', 'encypher-provenance')
                ),
                'disposition' => 'superseded_by_pending_commit',
            ];
        }

        try {
            if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                return $this->fenced_attachment_commit(
                    $attachment_id,
                    $lock_token,
                    $signed_bytes,
                    $original_bytes
                );
            }
            $written = MediaWriter::write($attachment_id, $signed_bytes, $original_bytes);
            if (is_wp_error($written)) {
                $write_error_code = $written->get_error_code();
                $newer_commit_exists = 'source_changed' === $write_error_code
                    && self::attachment_has_current_signed_commit($attachment_id);
                if ($newer_commit_exists) {
                    return [
                        'written' => false,
                        'error' => null,
                        'disposition' => 'superseded_by_current_commit',
                    ];
                }
                if (in_array($write_error_code, ['restore_failed', 'source_changed'], true)) {
                    self::invalidate_attachment_provenance($attachment_id);
                }
                return [
                    'written' => false,
                    'error' => $written,
                    'disposition' => 'source_changed' === $write_error_code
                        ? 'source_changed_invalidated'
                        : 'write_failed',
                ];
            }

            $this->attachment_commit_checkpoint('bytes_written', $attachment_id);
            if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                return $this->fenced_attachment_commit(
                    $attachment_id,
                    $lock_token,
                    $signed_bytes,
                    $original_bytes
                );
            }

            $public_status = $success_meta['_encypher_provenance_status'] ?? null;
            unset($success_meta['_encypher_provenance_status']);
            delete_post_meta($attachment_id, '_encypher_provenance_status');
            delete_post_meta($attachment_id, '_encypher_last_sign_error');

            foreach ($success_meta as $meta_key => $meta_value) {
                if (null === $meta_value) {
                    continue;
                }
                if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                    return $this->fenced_attachment_commit(
                        $attachment_id,
                        $lock_token,
                        $signed_bytes,
                        $original_bytes
                    );
                }
                update_post_meta($attachment_id, $meta_key, $meta_value);
                $this->attachment_commit_checkpoint('metadata_written', $attachment_id);
                if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                    return $this->fenced_attachment_commit(
                        $attachment_id,
                        $lock_token,
                        $signed_bytes,
                        $original_bytes
                    );
                }
            }

            if (null !== $rights_result) {
                if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                    return $this->fenced_attachment_commit(
                        $attachment_id,
                        $lock_token,
                        $signed_bytes,
                        $original_bytes
                    );
                }
                self::persist_post_rights_evidence($attachment_id, $rights_result);
                $this->attachment_commit_checkpoint('rights_metadata_written', $attachment_id);
                if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                    return $this->fenced_attachment_commit(
                        $attachment_id,
                        $lock_token,
                        $signed_bytes,
                        $original_bytes
                    );
                }
            }

            update_post_meta($attachment_id, '_encypher_signed_file_hash', 'sha256:' . hash('sha256', $signed_bytes));
            $this->attachment_commit_checkpoint('metadata_committed', $attachment_id);
            if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                return $this->fenced_attachment_commit(
                    $attachment_id,
                    $lock_token,
                    $signed_bytes,
                    $original_bytes
                );
            }

            $purged = VipMediaDelivery::purge_after_verified_write($attachment_id);
            if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                return $this->fenced_attachment_commit(
                    $attachment_id,
                    $lock_token,
                    $signed_bytes,
                    $original_bytes
                );
            }
            if (null !== $public_status) {
                update_post_meta($attachment_id, '_encypher_provenance_status', $public_status);
            }
            $this->attachment_commit_checkpoint('commit_succeeded', $attachment_id);
            if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                return $this->fenced_attachment_commit(
                    $attachment_id,
                    $lock_token,
                    $signed_bytes,
                    $original_bytes
                );
            }
            return [
                'written' => true,
                'error' => is_wp_error($purged) ? $purged : null,
                'disposition' => 'committed',
            ];
        } finally {
            $this->release_attachment_commit_lock($attachment_id, $lock_token);
        }
    }

    /**
     * Commit prepared-signing metadata only while the byte-replacement owner
     * still owns the attachment, durable operation, and exact signed bytes.
     *
     * @param array<string,mixed> $success_meta Sanitized attachment metadata.
     * @return array{written:bool,error:?WP_Error,disposition:string}
     */
    private function commit_prepared_attachment(
        int $attachment_id,
        string $signed_hash,
        array $success_meta,
        string $operation_key,
        string $lock_token
    ): array {
        $rollback = [];
        $superseded = function () use ($attachment_id, &$rollback): array {
            $this->rollback_prepared_attachment_meta($attachment_id, $rollback);
            return [
                'written' => false,
                'error' => null,
                'disposition' => 'superseded_by_current_commit',
            ];
        };
        try {
            if (! $this->owns_prepared_attachment_commit(
                $attachment_id,
                $signed_hash,
                $operation_key,
                $lock_token
            )) {
                return $superseded();
            }

            $public_status = $success_meta['_encypher_provenance_status'] ?? null;
            unset($success_meta['_encypher_provenance_status']);
            foreach ($success_meta as $meta_key => $meta_value) {
                if (null !== $meta_value) {
                    $this->project_prepared_attachment_meta(
                        $attachment_id,
                        $meta_key,
                        $meta_value,
                        $rollback
                    );
                }
            }
            $this->project_prepared_attachment_meta(
                $attachment_id,
                '_encypher_signed_file_hash',
                sanitize_text_field($signed_hash),
                $rollback
            );
            $this->attachment_commit_checkpoint('prepared_metadata_written', $attachment_id);
            if (! $this->owns_prepared_attachment_commit(
                $attachment_id,
                $signed_hash,
                $operation_key,
                $lock_token
            )) {
                return $superseded();
            }

            $delivery_key = '_encypher_public_delivery_state';
            $delivery_existed = $this->attachment_meta_exists($attachment_id, $delivery_key);
            $delivery_previous = get_post_meta($attachment_id, $delivery_key, true);
            $purged = VipMediaDelivery::purge_after_verified_write($attachment_id);
            $delivery_after_exists = $this->attachment_meta_exists($attachment_id, $delivery_key);
            $delivery_after = get_post_meta($attachment_id, $delivery_key, true);
            if (
                $delivery_existed !== $delivery_after_exists
                || $delivery_previous !== $delivery_after
            ) {
                $rollback[$delivery_key] = [
                    'previous_exists' => $delivery_existed,
                    'previous' => $delivery_previous,
                    'written_exists' => $delivery_after_exists,
                    'written' => $delivery_after,
                ];
            }
            $this->attachment_commit_checkpoint('prepared_purge_completed', $attachment_id);
            if (! $this->owns_prepared_attachment_commit(
                $attachment_id,
                $signed_hash,
                $operation_key,
                $lock_token
            )) {
                return $superseded();
            }

            if (null !== $public_status) {
                $this->project_prepared_attachment_meta(
                    $attachment_id,
                    '_encypher_provenance_status',
                    $public_status,
                    $rollback
                );
            }
            delete_post_meta($attachment_id, '_encypher_last_sign_error');
            $this->attachment_commit_checkpoint('prepared_commit_succeeded', $attachment_id);
            if (! $this->owns_prepared_attachment_commit(
                $attachment_id,
                $signed_hash,
                $operation_key,
                $lock_token
            )) {
                return $superseded();
            }
            return [
                'written' => true,
                'error' => is_wp_error($purged) ? $purged : null,
                'disposition' => 'committed',
            ];
        } finally {
            $this->release_attachment_commit_lock($attachment_id, $lock_token);
        }
    }

    private function attachment_meta_exists(int $attachment_id, string $meta_key): bool
    {
        if (function_exists(__NAMESPACE__ . '\\metadata_exists') || function_exists('metadata_exists')) {
            return metadata_exists('post', $attachment_id, $meta_key);
        }
        return '' !== get_post_meta($attachment_id, $meta_key, true);
    }

    /**
     * @param array<string,array{previous_exists:bool,previous:mixed,written_exists:bool,written:mixed}> $rollback
     */
    private function project_prepared_attachment_meta(
        int $attachment_id,
        string $meta_key,
        $value,
        array &$rollback
    ): void {
        $rollback[$meta_key] = [
            'previous_exists' => $this->attachment_meta_exists($attachment_id, $meta_key),
            'previous' => get_post_meta($attachment_id, $meta_key, true),
            'written_exists' => true,
            'written' => $value,
        ];
        update_post_meta($attachment_id, $meta_key, $value);
    }

    /**
     * Roll back only values still equal to this exact prepared projection.
     *
     * @param array<string,array{previous_exists:bool,previous:mixed,written_exists:bool,written:mixed}> $rollback
     */
    private function rollback_prepared_attachment_meta(int $attachment_id, array $rollback): void
    {
        foreach (array_reverse($rollback, true) as $meta_key => $entry) {
            $current_exists = $this->attachment_meta_exists($attachment_id, $meta_key);
            $current = get_post_meta($attachment_id, $meta_key, true);
            // Absence has no exact compare-and-swap token. Never restore over
            // a winner that intentionally kept the key absent.
            if (! $entry['written_exists']) {
                continue;
            }
            if (
                $current_exists !== $entry['written_exists']
                || ($current_exists && $current !== $entry['written'])
            ) {
                continue;
            }
            if ($entry['previous_exists']) {
                if ($current_exists) {
                    update_post_meta($attachment_id, $meta_key, $entry['previous'], $entry['written']);
                } else {
                    update_post_meta($attachment_id, $meta_key, $entry['previous']);
                }
            } elseif ($current_exists) {
                delete_post_meta($attachment_id, $meta_key, $entry['written']);
            }
        }
    }

    private function owns_media_operation(int $attachment_id, string $operation_key): bool
    {
        $pending = get_post_meta($attachment_id, '_encypher_media_operation_pending', true);
        return is_array($pending)
            && hash_equals($operation_key, (string) ($pending['generation'] ?? ''));
    }

    private function owns_prepared_attachment_commit(
        int $attachment_id,
        string $signed_hash,
        string $operation_key,
        string $lock_token
    ): bool {
        $expected = strtolower(trim($signed_hash));
        if (0 === strpos($expected, 'sha256:')) {
            $expected = substr($expected, 7);
        }
        if (
            1 !== preg_match('/\A[0-9a-f]{64}\z/', $expected)
            || ! $this->owns_attachment_commit_lock($attachment_id, $lock_token)
            || ! $this->owns_media_operation($attachment_id, $operation_key)
        ) {
            return false;
        }
        $file_path = get_attached_file($attachment_id);
        $current = is_string($file_path) && '' !== $file_path
            ? hash_file('sha256', $file_path)
            : false;
        return is_string($current) && hash_equals($expected, $current);
    }

    /**
     * Capture the exact attachment state before one rich result may mutate it.
     *
     * @param array<string,mixed> $item
     * @return array<string,mixed>|null
     */
    private function capture_rich_attachment_rollback(array $item, string $generation): ?array
    {
        $attachment_id = (int) ($item['attachment_id'] ?? 0);
        $original_bytes = $item['_encypher_original_bytes'] ?? null;
        if ($attachment_id < 1 || ! is_string($original_bytes)) {
            return null;
        }

        $meta = [];
        foreach (self::RICH_ATTACHMENT_ROLLBACK_META_KEYS as $meta_key) {
            $meta[$meta_key] = [
                'previous_exists' => $this->attachment_meta_exists($attachment_id, $meta_key),
                'previous' => get_post_meta($attachment_id, $meta_key, true),
            ];
        }
        return [
            'attachment_id' => $attachment_id,
            'asset_type' => (string) ($item['media_type'] ?? ''),
            'generation' => $generation,
            'original_bytes' => $original_bytes,
            'meta' => $meta,
        ];
    }

    /**
     * Bind a rollback snapshot to the exact generation-owned bytes and metadata
     * that are live after the attachment state machine returns.
     *
     * @param array<string,mixed>|null $rollback
     * @return array<string,mixed>|null
     */
    private function seal_rich_attachment_rollback(?array $rollback): ?array
    {
        if (null === $rollback) {
            return null;
        }
        $attachment_id = (int) ($rollback['attachment_id'] ?? 0);
        $generation = (string) ($rollback['generation'] ?? '');
        if (
            $attachment_id < 1
            || '' === $generation
            || ! hash_equals(
                $generation,
                (string) get_post_meta($attachment_id, '_encypher_signed_generation', true)
            )
        ) {
            return null;
        }
        $file_path = get_attached_file($attachment_id);
        if (! is_string($file_path) || '' === $file_path || ! is_file($file_path)) {
            return null;
        }
        // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- exact local bytes fence rollback against a newer attachment winner.
        $written_bytes = @file_get_contents($file_path);
        if (false === $written_bytes) {
            return null;
        }

        foreach ($rollback['meta'] as $meta_key => &$entry) {
            $entry['written_exists'] = $this->attachment_meta_exists($attachment_id, (string) $meta_key);
            $entry['written'] = get_post_meta($attachment_id, (string) $meta_key, true);
        }
        unset($entry);
        $rollback['written_bytes'] = $written_bytes;
        return $rollback;
    }

    /**
     * Restore only attachments still owned by the failed parent generation.
     * A current or pending independent attachment winner is never overwritten.
     *
     * @param array<int,array<string,mixed>> $rollbacks
     */
    private function rollback_rich_attachments(array $rollbacks): void
    {
        foreach (array_reverse($rollbacks) as $rollback) {
            if (! is_string($rollback['written_bytes'] ?? null)) {
                $rollback = $this->seal_rich_attachment_rollback($rollback);
                if (null === $rollback) {
                    continue;
                }
            }
            $attachment_id = (int) ($rollback['attachment_id'] ?? 0);
            $generation = (string) ($rollback['generation'] ?? '');
            $original_bytes = $rollback['original_bytes'] ?? null;
            $written_bytes = $rollback['written_bytes'] ?? null;
            if (
                $attachment_id < 1
                || '' === $generation
                || ! is_string($original_bytes)
                || ! is_string($written_bytes)
            ) {
                continue;
            }

            $lock_token = $this->acquire_attachment_commit_lock($attachment_id);
            if (null === $lock_token) {
                continue;
            }
            try {
                if (
                    ! $this->owns_attachment_commit_lock($attachment_id, $lock_token)
                    || ! hash_equals(
                        $generation,
                        (string) get_post_meta($attachment_id, '_encypher_signed_generation', true)
                    )
                ) {
                    continue;
                }
                $file_path = get_attached_file($attachment_id);
                // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- exact current bytes are the rollback CAS token.
                $current_bytes = is_string($file_path) && '' !== $file_path
                    // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- get_attached_file returned a local path; exact bytes are the rollback CAS token.
                    ? @file_get_contents($file_path)
                    : false;
                if (
                    false === $current_bytes
                    || ! hash_equals(hash('sha256', $written_bytes), hash('sha256', $current_bytes))
                ) {
                    continue;
                }

                $restored = MediaWriter::write($attachment_id, $original_bytes, $written_bytes);
                if (is_wp_error($restored)) {
                    if ('source_changed' !== $restored->get_error_code()) {
                        delete_post_meta($attachment_id, '_encypher_public_delivery_state');
                        self::invalidate_attachment_provenance($attachment_id);
                    }
                    continue;
                }
                if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                    continue;
                }

                // The signed bytes may already be live at an edge. Purge the
                // restored source before restoring its prior delivery state.
                VipMediaDelivery::purge_after_verified_write($attachment_id);
                if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                    continue;
                }
                $delivery_key = '_encypher_public_delivery_state';
                if (isset($rollback['meta'][$delivery_key])) {
                    $rollback['meta'][$delivery_key]['written_exists'] =
                        $this->attachment_meta_exists($attachment_id, $delivery_key);
                    $rollback['meta'][$delivery_key]['written'] =
                        get_post_meta($attachment_id, $delivery_key, true);
                }

                foreach (array_reverse($rollback['meta'], true) as $meta_key => $entry) {
                    if (! $this->owns_attachment_commit_lock($attachment_id, $lock_token)) {
                        break;
                    }
                    $current_exists = $this->attachment_meta_exists($attachment_id, (string) $meta_key);
                    $current = get_post_meta($attachment_id, (string) $meta_key, true);
                    if (
                        $current_exists !== (bool) ($entry['written_exists'] ?? false)
                        || ($current_exists && $current !== ($entry['written'] ?? null))
                    ) {
                        continue;
                    }
                    if (! empty($entry['previous_exists'])) {
                        if ($current_exists) {
                            update_post_meta(
                                $attachment_id,
                                (string) $meta_key,
                                $entry['previous'],
                                $entry['written']
                            );
                        } else {
                            update_post_meta($attachment_id, (string) $meta_key, $entry['previous']);
                        }
                    } elseif ($current_exists) {
                        delete_post_meta($attachment_id, (string) $meta_key, $entry['written']);
                    }
                }
            } finally {
                $this->release_attachment_commit_lock($attachment_id, $lock_token);
            }
        }
    }

    /**
     * Return every metadata-referenced derivative path except the attached
     * source itself. Recursing covers registered size variants and nested
     * alternate-format sources without assuming a fixed metadata extension.
     *
     * @return string[]
     */
    private function rich_attachment_derivative_paths(array $metadata, string $source_path): array
    {
        $filenames = [];
        $collect = static function ($value) use (&$collect, &$filenames): void {
            if (! is_array($value)) {
                return;
            }
            foreach ($value as $key => $item) {
                if ('file' === (string) $key && is_string($item) && '' !== $item) {
                    $filenames[] = basename($item);
                    continue;
                }
                if (is_array($item)) {
                    $collect($item);
                }
            }
        };
        $collect($metadata);

        $source_basename = basename($source_path);
        $directory = dirname($source_path);
        $paths = [];
        foreach (array_unique($filenames) as $filename) {
            if ('' === $filename || $source_basename === $filename) {
                continue;
            }
            $paths[] = $directory . '/' . $filename;
        }
        return $paths;
    }

    /**
     * Publish the full-size-only derivative state after the rich parent has
     * committed. Until this point the prior attachment metadata and every
     * derivative remain untouched, so a failed or superseded parent needs no
     * filesystem compensation for them.
     *
     * Each attachment is re-fenced under its commit lock against both the
     * generation and exact signed source bytes. A newer attachment winner is
     * therefore never resized, deleted, or assigned this generation's
     * metadata.
     *
     * @param array<int,array<string,mixed>> $rollbacks
     */
    private function publish_rich_attachment_derivatives(array $rollbacks): void
    {
        foreach ($rollbacks as $rollback) {
            $attachment_id = (int) ($rollback['attachment_id'] ?? 0);
            $generation = (string) ($rollback['generation'] ?? '');
            $written_bytes = $rollback['written_bytes'] ?? null;
            $metadata_fence = $rollback['meta']['_wp_attachment_metadata'] ?? null;
            if (
                $attachment_id < 1
                || 'image' !== (string) ($rollback['asset_type'] ?? '')
                || '' === $generation
                || ! is_string($written_bytes)
                || ! is_array($metadata_fence)
            ) {
                continue;
            }

            $lock_token = $this->acquire_attachment_commit_lock($attachment_id);
            if (null === $lock_token) {
                continue;
            }
            try {
                $owns_derivative_publish = function () use (
                    $attachment_id,
                    $generation,
                    $written_bytes,
                    $metadata_fence,
                    $lock_token
                ): bool {
                    if (
                        ! $this->owns_attachment_commit_lock($attachment_id, $lock_token)
                        || ! hash_equals(
                            $generation,
                            (string) get_post_meta($attachment_id, '_encypher_signed_generation', true)
                        )
                    ) {
                        return false;
                    }
                    $metadata_exists = $this->attachment_meta_exists(
                        $attachment_id,
                        '_wp_attachment_metadata'
                    );
                    $expected_metadata_exists = (bool) ($metadata_fence['written_exists'] ?? false);
                    if (
                        $metadata_exists !== $expected_metadata_exists
                        || (
                            $metadata_exists
                            && get_post_meta($attachment_id, '_wp_attachment_metadata', true)
                                !== ($metadata_fence['written'] ?? null)
                        )
                    ) {
                        return false;
                    }
                    $file_path = get_attached_file($attachment_id);
                    $current_hash = is_string($file_path) && '' !== $file_path && is_file($file_path)
                        ? hash_file('sha256', $file_path)
                        : false;
                    return is_string($current_hash)
                        && hash_equals(hash('sha256', $written_bytes), $current_hash);
                };
                if (! $owns_derivative_publish()) {
                    continue;
                }
                $this->attachment_commit_checkpoint('rich_derivatives_locked', $attachment_id);
                if (! $owns_derivative_publish()) {
                    continue;
                }

                $file_path = get_attached_file($attachment_id);
                if (! is_string($file_path) || '' === $file_path || ! is_file($file_path)) {
                    continue;
                }
                $old_meta = wp_get_attachment_metadata($attachment_id);
                $old_meta = is_array($old_meta) ? $old_meta : [];
                foreach ($this->rich_attachment_derivative_paths($old_meta, $file_path) as $derivative_path) {
                    if (! $owns_derivative_publish()) {
                        continue 2;
                    }
                    if (is_file($derivative_path)) {
                        wp_delete_file($derivative_path);
                    }
                }

                if (! $owns_derivative_publish()) {
                    continue;
                }
                $new_meta = wp_generate_attachment_metadata($attachment_id, $file_path);
                if (! $owns_derivative_publish()) {
                    continue;
                }
                $published_meta = is_array($new_meta) && ! empty($new_meta)
                    ? $new_meta
                    : $old_meta;
                foreach ($this->rich_attachment_derivative_paths($published_meta, $file_path) as $derivative_path) {
                    if (! $owns_derivative_publish()) {
                        continue 2;
                    }
                    if (is_file($derivative_path)) {
                        wp_delete_file($derivative_path);
                    }
                }
                if (! $owns_derivative_publish()) {
                    continue;
                }
                $published_meta['sizes'] = [];
                wp_update_attachment_metadata($attachment_id, $published_meta);
                Logger::log(sprintf(
                    'Encypher: published full-size-only derivatives for attach=%d',
                    $attachment_id
                ));
            } finally {
                $this->release_attachment_commit_lock($attachment_id, $lock_token);
            }
        }
    }

    /**
     * Validate and persist one signed media result from /sign/rich.
     *
     * @return array{written:bool,filename:string,error:?WP_Error}
     */
    private function persist_signed_media_item(
        array $item,
        array $result,
        string $asset_type,
        string $bytes_key,
        string $hash_key,
        string $document_id,
        string $generation
    ): array {
        $filename = basename((string) ($item['file_path'] ?? ''));
        if ($asset_type !== (string) ($item['media_type'] ?? '')) {
            return [
                'written' => false,
                'filename' => $filename,
                'error' => new WP_Error(
                    'media_type_mismatch',
                    __('The signing response media type did not match the requested asset.', 'encypher-provenance')
                ),
                'disposition' => 'terminal_failure',
            ];
        }

        $instance_id = trim((string) ($result['c2pa_manifest_instance_id'] ?? ''));
        $signed_hash = strtolower(trim((string) ($result[$hash_key] ?? '')));
        $signed_bytes = self::validate_signed_media_evidence(
            $result[$bytes_key] ?? '',
            $signed_hash,
            $instance_id,
            $asset_type,
            true === ($result['c2pa_signed'] ?? false)
        );
        if (is_wp_error($signed_bytes)) {
            return [
                'written' => false,
                'filename' => $filename,
                'error' => $signed_bytes,
                'disposition' => 'terminal_failure',
            ];
        }

        $file_path = get_attached_file((int) ($item['attachment_id'] ?? 0));
        if (! is_string($file_path) || '' === $file_path) {
            return [
                'written' => false,
                'filename' => '(unknown)',
                'error' => new WP_Error(
                    'missing_attached_file',
                    __('get_attached_file() returned empty for this attachment.', 'encypher-provenance')
                ),
                'disposition' => 'terminal_failure',
            ];
        }
        $filename = basename($file_path);
        $attachment_id = (int) $item['attachment_id'];
        $mime_type = function_exists('get_post_mime_type')
            ? (string) get_post_mime_type($attachment_id)
            : '';
        if ('' === $mime_type) {
            $mime_type = isset($item['mime_type']) ? (string) $item['mime_type'] : 'application/octet-stream';
        }
        $verified = $this->verify_signed_media_before_write(
            $signed_bytes,
            $mime_type,
            $filename,
            $instance_id
        );
        if (is_wp_error($verified)) {
            return [
                'written' => false,
                'filename' => $filename,
                'error' => $verified,
                'disposition' => 'terminal_failure',
            ];
        }

        $commit = $this->write_and_commit_signed_attachment(
            $attachment_id,
            $signed_bytes,
            (string) ($item['_encypher_original_bytes'] ?? ''),
            [
                '_encypher_c2pa_instance_id' => sanitize_text_field($instance_id),
                '_encypher_signed_hash' => sanitize_text_field($signed_hash),
                '_encypher_signed_at' => current_time('mysql'),
                '_encypher_provenance_status' => 'signed',
                '_encypher_signed_as_part_of' => sanitize_text_field($document_id),
                '_encypher_signed_generation' => $generation,
                '_encypher_signed_action' => ! empty($item['has_existing_c2pa']) ? 'c2pa.placed' : 'c2pa.created',
            ],
            $result
        );
        return [
            'written' => $commit['written'],
            'filename' => $filename,
            'error' => $commit['error'],
            'disposition' => $commit['disposition'],
        ];
    }

    /**
     * Convert the attachment state machine result into the only outcomes the
     * rich parent may use. A takeover winner counts only when its committed
     * attachment metadata is bound to this exact requested generation.
     *
     * @param array{written:bool,filename:string,error:?WP_Error,disposition:string} $outcome
     * @return array{classification:string,committed:bool,error:?WP_Error}
     */
    private static function classify_rich_media_outcome(
        int $attachment_id,
        string $generation,
        array $outcome
    ): array {
        $disposition = (string) ($outcome['disposition'] ?? 'terminal_failure');
        if (true === ($outcome['written'] ?? false) && 'committed' === $disposition) {
            return [
                'classification' => 'committed',
                'committed' => true,
                'error' => $outcome['error'] ?? null,
            ];
        }
        if (
            in_array($disposition, ['superseded_by_current_commit', 'takeover_lost'], true)
            && self::attachment_has_current_signed_commit($attachment_id, $generation)
        ) {
            return [
                'classification' => 'winner_preserved',
                'committed' => true,
                'error' => null,
            ];
        }

        $error = $outcome['error'] ?? null;
        if (! is_wp_error($error)) {
            $error = new WP_Error(
                'media_commit_incomplete',
                'expired_restored' === $disposition
                    ? __('Signed attachment bytes were rolled back after the commit lease expired.', 'encypher-provenance')
                    : __('The requested attachment signing generation did not commit.', 'encypher-provenance')
            );
        }
        return [
            'classification' => 'expired_restored' === $disposition
                ? 'incomplete_recovered'
                : 'terminal_failure',
            'committed' => false,
            'error' => $error,
        ];
    }

    private function is_signed_html_artifact(string $signed_text): bool
    {
        $visible_html = $this->strip_c2pa_embeddings($signed_text);
        return $visible_html !== $signed_text
            && 1 === preg_match(
                '/<!--\s+wp:|<(?:p|h[1-6]|figure|blockquote|ul|ol|li)\b/i',
                $visible_html
            );
    }

    /**
     * Process the /sign/rich response: embed signed text, replace media files,
     * store composite manifest metadata.
     *
     * @param array  $response       Decoded JSON from /sign/rich.
     * @param int    $post_id        WordPress post ID.
     * @param string $clean_content  Block-comment-sanitized post HTML.
     * @param string $extracted_text Plain text used for signing.
     * @param array  $media_items    Media descriptors from MediaBlockExtractor.
     * @param string $signing_mode   'managed' or 'byok'.
     * @param string $signing_profile_id BYOK profile ID (empty for managed).
     * @param array  $settings       Plugin settings array.
     *
     * @return WP_REST_Response|WP_Error
     */
    private function process_rich_sign_response(
        array $response,
        int $post_id,
        string $clean_content,
        string $extracted_text,
        array $media_items,
        string $signing_mode,
        string $signing_profile_id,
        array $settings,
        string $generation = '',
        array $attempt = []
    ) {
        if ('' === $generation) {
            $generation = (string) $this->begin_text_generation($post_id);
            if (function_exists(__NAMESPACE__ . '\\get_post') || function_exists('get_post')) {
                $post = get_post($post_id);
                if (! $post) {
                    return new WP_Error('invalid_post', __('Invalid post.', 'encypher-provenance'), ['status' => 400]);
                }
            } else {
                // Standalone contract tests do not boot WordPress. Runtime
                // always resolves the real post above.
                $post = (object) [
                    'ID' => $post_id,
                    'post_title' => '',
                    'post_excerpt' => '',
                    'post_content' => $clean_content,
                    'post_modified_gmt' => '',
                ];
            }
            $assurance = get_post_meta($post_id, '_encypher_publication_assurance', true);
            $post = $this->apply_protected_signing_candidate($post, $assurance);
            $attempt = $this->capture_text_attempt($post, $clean_content, $media_items);
        }
        $data = $response['data'] ?? [];

        // wp_generate_attachment_metadata() (used below to regenerate
        // thumbnails for signed media) lives in wp-admin/includes/image.php,
        // which is NOT loaded during REST or WP-Cron requests. Without this the
        // rich-sign path fatals partway through — media files get written but
        // _encypher_marked / document_id are never set, leaving the post
        // half-signed (frontend serves clean content). Load it on demand so
        // signing completes in every execution context.
        if (! function_exists('wp_generate_attachment_metadata')) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
        }

        // 1. Process text result.
        $text_result = $data['text'] ?? [];
        $signed_text = $this->resolve_signed_text_with_embedding_plan($text_result, $extracted_text);
        if (!is_string($signed_text) || '' === $signed_text) {
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error('invalid_response', __('No signed text in /sign/rich response.', 'encypher-provenance'), ['status' => 502])
            );
        }

        $embed_stats = null;
        $signed_html = $this->is_signed_html_artifact($signed_text);
        if ($signed_html) {
            // Production /sign/rich returns a marker-bearing Gutenberg HTML
            // artifact. WordPress reparses block HTML before serving it, so
            // storing that artifact directly invalidates markers embedded in
            // comments and attributes. Project its marker-bearing visible text
            // back into the candidate's text nodes instead.
            $candidate_visible = $this->get_parser()->canonical_visible_text($clean_content);
            $artifact_visible = $this->get_parser()->canonical_visible_text($signed_text);
            $content_matches = '' !== $candidate_visible
                && hash_equals($candidate_visible, $artifact_visible);
            $signed_projection = $this->get_parser()->extract_text_for_verify($signed_text);
            $embedded_content = $this->embed_signed_text_in_html(
                $clean_content,
                $signed_projection,
                $embed_stats
            );
            if (is_array($embed_stats)) {
                $embed_stats['complete'] = ! empty($embed_stats['complete']) && $content_matches;
            }
        } else {
            $embedded_content = $this->embed_signed_text_in_html($clean_content, $signed_text, $embed_stats);
        }
        $embed_check = $this->detect_c2pa_embeddings($embedded_content);
        $embed_complete = !is_array($embed_stats) || !empty($embed_stats['complete']);
        $leaf_pointer_layer = is_array($text_result['leaf_pointer_layer'] ?? null)
            ? $text_result['leaf_pointer_layer']
            : [];
        $pointer_presentation = $this->has_leaf_pointer_presentation(
            $leaf_pointer_layer,
            $embedded_content
        );
        $embedding_valid = (
            $embed_check['count'] >= 1
            || $pointer_presentation
        ) && $embed_complete;
        $projection_guard = $this->open_text_projection_guard($post_id, $generation, $attempt);
        if (null === $projection_guard) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('text_embedding_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $embedding_valid) {
            $this->debug_log(sprintf(
                'Rich embedding invalid for post %d: html=%d wrappers=%d complete=%d matched=%d total=%d failed=%d candidate_sha256=%s artifact_sha256=%s projection_bytes=%d',
                $post_id,
                $signed_html ? 1 : 0,
                (int) $embed_check['count'],
                $embed_complete ? 1 : 0,
                (int) ($embed_stats['matched'] ?? 0),
                (int) ($embed_stats['total'] ?? 0),
                (int) ($embed_stats['failed_nonempty'] ?? 0),
                hash('sha256', (string) ($candidate_visible ?? '')),
                hash('sha256', (string) ($artifact_visible ?? '')),
                strlen((string) ($signed_projection ?? ''))
            ));
        }
        if (! $embedding_valid) {
            return $this->fail_text_generation(
                $post_id,
                $generation,
                new WP_Error(
                    'c2pa_embed_persist_failed',
                    __('Signed text could not be embedded in the post content.', 'encypher-provenance'),
                    ['status' => 500]
                )
            );
        }
        $projection_error = $this->required_projection_error($post_id, $generation, [
            $this->project_generation_meta($post_id, '_encypher_text_embedding_status', 'embedded'),
            $this->project_generation_meta($post_id, '_encypher_text_embedding_checked_at', gmdate('c')),
        ]);
        if (null !== $projection_error) {
            return $projection_error;
        }

        // WS1 editor-clean storage applies to rich/composite signing too:
        // keep post_content marker-free for the editor and store the
        // marker-bearing HTML in _encypher_signed_text for render-time
        // injection. The byte-level embedding invariant is enforced above.
        if (! $this->owns_text_mutation('content', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $this->is_signing = true;
        $updated = $this->project_or_stage_generation_content($post_id, $generation, $clean_content);
        $this->is_signing = false;

        if (! $this->write_result_succeeded($updated)) {
            return self::WRITE_DB_ERROR === $updated
                ? $this->fail_text_generation($post_id, $generation, $this->write_result_error($updated))
                : $this->text_generation_fenced_error();
        }
        $post_after_update = function_exists(__NAMESPACE__ . '\\get_post') || function_exists('get_post')
            ? get_post($post_id)
            : null;
        if ($post_after_update) {
            $attempt['revision'] = (string) ($post_after_update->post_modified_gmt ?? ($post_after_update->post_modified ?? ''));
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        $rich_media_rollbacks = [];
        $rich_generation_committed = false;
        try {


        // 2. Validate and persist every signed media result through one
        // descriptor contract shared by images, audio, and video.
        $attachment_map = [];
        foreach ($media_items as $item) {
            $attachment_map[(int) $item['position']] = $item;
        }

        $signed_images = isset($data['images']) && is_array($data['images']) ? $data['images'] : [];
        $signed_audios = isset($data['audios']) && is_array($data['audios']) ? $data['audios'] : [];
        $signed_videos = isset($data['videos']) && is_array($data['videos']) ? $data['videos'] : [];
        $media_write_errors = [];
        $completed_media_positions = [];
        $seen_media_positions = [];
        $journaled_attachment_ids = [];
        $signed_image_positions = [];
        $written_counts = ['image' => 0, 'audio' => 0, 'video' => 0];
        $media_result_sets = [
            'image' => [
                'results' => $signed_images,
                'bytes_key' => 'signed_image_b64',
                'hash_key' => 'signed_image_hash',
            ],
            'audio' => [
                'results' => $signed_audios,
                'bytes_key' => 'signed_audio_b64',
                'hash_key' => 'signed_audio_hash',
            ],
            'video' => [
                'results' => $signed_videos,
                'bytes_key' => 'signed_video_b64',
                'hash_key' => 'signed_video_hash',
            ],
        ];

        foreach ($media_result_sets as $asset_type => $config) {
            $results = is_array($config['results']) ? $config['results'] : [];
            foreach ($results as $media_result) {
                if (! is_array($media_result)) {
                    continue;
                }
                if (! array_key_exists('position', $media_result) || ! is_int($media_result['position'])) {
                    Logger::log(sprintf('Encypher: %s result has an invalid position, skipping', $asset_type));
                    continue;
                }
                $position = $media_result['position'];
                if (! isset($attachment_map[$position])) {
                    Logger::log(sprintf(
                        'Encypher: %s pos=%d not in attachment_map, skipping',
                        $asset_type,
                        $position
                    ));
                    continue;
                }
                if (isset($seen_media_positions[$position])) {
                    $media_write_errors[] = [
                        'asset_type' => $asset_type,
                        'filename' => basename((string) ($attachment_map[$position]['file_path'] ?? '')),
                        'position' => $position,
                        'error_code' => 'duplicate_media_result',
                        'error_message' => __('The signing response returned this media position more than once.', 'encypher-provenance'),
                    ];
                    continue;
                }
                $seen_media_positions[$position] = true;


                if (! $this->owns_text_mutation('media_item', $post_id, $generation, $attempt)) {
                    return $this->text_generation_fenced_error();
                }

                $completed_media_positions[$position] = true;
                $attachment_id = (int) ($attachment_map[$position]['attachment_id'] ?? 0);
                $attachment_rollback = isset($journaled_attachment_ids[$attachment_id])
                    ? null
                    : $this->capture_rich_attachment_rollback(
                        $attachment_map[$position],
                        $generation
                    );
                $rollback_index = null;
                if (null !== $attachment_rollback) {
                    $rollback_index = count($rich_media_rollbacks);
                    $rich_media_rollbacks[$rollback_index] = $attachment_rollback;
                    $journaled_attachment_ids[$attachment_id] = true;
                }
                $outcome = $this->persist_signed_media_item(
                    $attachment_map[$position],
                    $media_result,
                    $asset_type,
                    (string) $config['bytes_key'],
                    (string) $config['hash_key'],
                    (string) ($data['document_id'] ?? ''),
                    $generation
                );
                $attachment_rollback = $this->seal_rich_attachment_rollback($attachment_rollback);
                if (null !== $attachment_rollback && null !== $rollback_index) {
                    $rich_media_rollbacks[$rollback_index] = $attachment_rollback;
                } elseif (null !== $rollback_index) {
                    unset($rich_media_rollbacks[$rollback_index], $journaled_attachment_ids[$attachment_id]);
                }
                // Once the attachment state machine returns, bind any live
                // bytes from this generation to the parent rollback journal.
                // The journal restores them unless the whole parent commits.
                $attempt['media'] = array_values(array_filter(
                    $attempt['media'] ?? [],
                    static function (array $input) use ($attachment_id): bool {
                        return (int) ($input['attachment_id'] ?? 0) !== $attachment_id;
                    }
                ));
                $classified = self::classify_rich_media_outcome(
                    $attachment_id,
                    $generation,
                    $outcome
                );
                if ($classified['committed']) {
                    ++$written_counts[$asset_type];
                    if ('image' === $asset_type) {
                        $signed_image_positions[$position] = true;
                    }
                }
                if (is_wp_error($classified['error'])) {
                    $media_write_errors[] = [
                        'asset_type' => $asset_type,
                        'filename' => $outcome['filename'],
                        'position' => $position,
                        'error_code' => $classified['error']->get_error_code(),
                        'error_message' => $classified['error']->get_error_message(),
                    ];
                }
            }
        }

        $images_written = $written_counts['image'];
        $audios_written = $written_counts['audio'];
        $videos_written = $written_counts['video'];

        $backend_sign_errors = isset($data['sign_errors']) && is_array($data['sign_errors'])
            ? $data['sign_errors']
            : [];
        foreach ($backend_sign_errors as $error) {
            if (
                is_array($error)
                && array_key_exists('position', $error)
                && is_int($error['position'])
            ) {
                $completed_media_positions[$error['position']] = true;
            }
        }
        foreach ($attachment_map as $position => $item) {
            if (isset($completed_media_positions[(int) $position])) {
                continue;
            }
            $media_write_errors[] = [
                'asset_type'    => sanitize_key((string) ($item['media_type'] ?? 'media')),
                'filename'      => basename((string) ($item['file_path'] ?? '')),
                'position'      => (int) $position,
                'error_code'    => 'missing_media_result',
                'error_message' => 'The signing response omitted this requested media item.',
            ];
        }
        $sign_errors = array_merge($backend_sign_errors, $media_write_errors);
        $media_signing_ok = empty($sign_errors);
        Logger::log(sprintf(
            'Encypher: media write summary: images=%d/%d audios=%d/%d videos=%d/%d',
            $images_written, count($signed_images),
            $audios_written, count($signed_audios),
            $videos_written, count($signed_videos)
        ));

        if (! $media_signing_ok) {
            $this->rollback_rich_attachments($rich_media_rollbacks);
            $rich_media_rollbacks = [];
            $images_written = 0;
            $audios_written = 0;
            $videos_written = 0;
            if (! $this->owns_text_mutation('media_failure', $post_id, $generation, $attempt)) {
                return $this->text_generation_fenced_error();
            }
            $failure_results = [
                $this->project_generation_meta($post_id, '_encypher_sign_errors', $sign_errors),
                $this->project_generation_meta($post_id, '_encypher_last_sign_error', [
                    'code' => 'media_signing_failed',
                    'message' => __('One or more requested media items could not be signed and committed.', 'encypher-provenance'),
                    'time' => time(),
                ]),
            ];
            $projection_error = $this->required_projection_error($post_id, $generation, $failure_results);
            if (null !== $projection_error) {
                return $projection_error;
            }
            $terminal = $this->abort_text_generation($post_id, $generation, 'media_signing_failed');
            if (! $this->write_result_succeeded($terminal['result'])) {
                return $this->write_result_error($terminal['result']);
            }
            $failure_data = [
                'status' => 'media_signing_failed',
                'signing_method' => 'rich',
                'media_signed' => [
                    'images' => $images_written,
                    'audios' => $audios_written,
                    'videos' => $videos_written,
                    'total' => $images_written + $audios_written + $videos_written,
                ],
                'sign_errors' => $sign_errors,
                'sign_error_count' => count($sign_errors),
            ];
            $failure_data = $this->add_terminal_cleanup_warning_to_data(
                $failure_data,
                $terminal['cleanup']
            );
            return new WP_REST_Response($failure_data);
        }

        // Attachment metadata and derivative files stay unchanged until the
        // parent generation commits. publish_rich_attachment_derivatives()
        // re-fences each attachment immediately before publishing the new
        // full-size-only state.
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        // 4c. The deleted derivative files may still be referenced by SAVED
        // block markup: Gutenberg serializes the selected sizeSlug URL into
        // post_content (e.g. photo-1024x1021.avif), so after the derivatives
        // are removed the editor - which renders saved markup without the
        // front-end rewrite filter - shows a broken image. Point the saved
        // markup at the signed full-size originals once, so every surface
        // (editor, feeds, filter-less renders) resolves an existing file.
        $rewritten_content = VipMediaDelivery::rewrite_signed_image_tags((string) $clean_content);
        if ($rewritten_content !== (string) $clean_content) {
            if (! $this->owns_text_mutation('image_markup', $post_id, $generation, $attempt)) {
                return $this->text_generation_fenced_error();
            }
            $clean_content = $rewritten_content;
            $this->is_signing = true;
            $rewrite_updated = $this->project_or_stage_generation_content($post_id, $generation, $clean_content);
            $this->is_signing = false;
            if (! $this->write_result_succeeded($rewrite_updated)) {
                return self::WRITE_DB_ERROR === $rewrite_updated
                    ? $this->fail_text_generation($post_id, $generation, $this->write_result_error($rewrite_updated))
                    : $this->text_generation_fenced_error();
            }
            $post_after_rewrite = get_post($post_id);
            if ($post_after_rewrite) {
                $attempt['revision'] = (string) (
                    $post_after_rewrite->post_modified_gmt
                    ?? ($post_after_rewrite->post_modified ?? '')
                );
                $assurance = get_post_meta($post_id, '_encypher_publication_assurance', true);
                $candidate_after_rewrite = $this->apply_protected_signing_candidate(
                    $post_after_rewrite,
                    $assurance
                );
                $attempt['candidate_digest'] = $this->candidate_generation_digest(
                    $candidate_after_rewrite,
                    $clean_content
                );
            }
            if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
                return $this->text_generation_fenced_error();
            }
            Logger::log(sprintf('Encypher: rewrote saved image markup for post %d onto signed originals', $post_id));
        }

        // 5. Extract text-level metadata.
        $document_id = sanitize_text_field((string) ($text_result['document_id'] ?? ''));
        $instance_id = '';
        if (!empty($text_result['instance_id'])) {
            $instance_id = sanitize_text_field((string) $text_result['instance_id']);
        } elseif (!empty($text_result['metadata']['instance_id'])) {
            $instance_id = sanitize_text_field((string) $text_result['metadata']['instance_id']);
        }

        $total_sentences = 0;
        if (isset($text_result['total_segments'])) {
            $total_sentences = (int) $text_result['total_segments'];
        } elseif (isset($text_result['total_sentences'])) {
            $total_sentences = (int) $text_result['total_sentences'];
        }

        $verification_id = $instance_id ?: $document_id;
        $verify_slug = Plugin::get_verify_slug();
        $verification_url = $verification_id ? home_url('/' . $verify_slug . '/') . urlencode($verification_id) : '';

        // 6. Store composite manifest post meta.
        $composite = $data['composite_manifest'] ?? [];
        if (! $this->owns_text_mutation('composite_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $composite_results = [
            $this->project_generation_meta($post_id, '_encypher_signing_method', 'rich'),
            $this->project_generation_meta($post_id, '_encypher_composite_manifest_instance_id', sanitize_text_field($composite['instance_id'] ?? '')),
            $this->project_generation_meta($post_id, '_encypher_composite_manifest_hash', sanitize_text_field($composite['manifest_hash'] ?? '')),
            $this->project_generation_meta($post_id, '_encypher_composite_ingredient_count', (int) ($composite['ingredient_count'] ?? 0)),
        ];
        if (!empty($composite['parent_manifest_uri'])) {
            $composite_results[] = $this->project_generation_meta($post_id, '_encypher_parent_manifest_uri', sanitize_text_field($composite['parent_manifest_uri']));
        }
        if (!empty($composite['manifest_url'])) {
            $composite_results[] = $this->project_generation_meta($post_id, '_encypher_manifest_url', esc_url_raw($composite['manifest_url']));
        }
        if (!empty($composite['compound_content_version'])) {
            $composite_results[] = $this->project_generation_meta($post_id, '_encypher_compound_content_version', sanitize_text_field($composite['compound_content_version']));
        }
        $projection_error = $this->required_projection_error($post_id, $generation, $composite_results);
        if (null !== $projection_error) {
            return $projection_error;
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        // Standard post meta (same as text-only path). Public identifiers are
        // committed only with marker-bearing text from this run.
        if (! $this->owns_text_mutation('public_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $public_results = [
            $this->project_generation_meta($post_id, '_encypher_marked', true),
            $this->project_generation_meta($post_id, '_encypher_provenance_cached_content_hash', md5((string) $clean_content)),
            $this->project_generation_meta($post_id, '_encypher_provenance_document_id', $document_id),
            $this->project_generation_meta($post_id, '_encypher_provenance_verification_url', $verification_url),
            $this->project_generation_meta($post_id, '_encypher_provenance_total_sentences', $total_sentences),
            $this->project_generation_meta($post_id, '_encypher_provenance_last_signed', current_time('mysql')),
            $this->project_generation_meta($post_id, '_encypher_manifest_id', $document_id),
            $this->project_generation_meta($post_id, '_encypher_marked_date', current_time('mysql')),
            $this->project_generation_meta($post_id, '_encypher_provenance_signing_mode', $signing_mode),
            $instance_id
                ? $this->project_generation_meta($post_id, '_encypher_provenance_instance_id', $instance_id)
                : $this->delete_generation_meta($post_id, '_encypher_provenance_instance_id'),
        ];
        $projection_error = $this->required_projection_error($post_id, $generation, $public_results);
        if (null !== $projection_error) {
            return $projection_error;
        }
        // The response projection intentionally replaced (or cleared) the
        // parent instance id. Continue fencing against that exact new value.
        $attempt['previous_instance_id'] = $instance_id;
        $completion_status = 'c2pa_protected';
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('rights_evidence', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $parent_evidence_source = array_merge($data, $text_result);
        $rights_projection = $this->project_post_rights_evidence($post_id, $parent_evidence_source);
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            [$rights_projection['result']]
        );
        if (null !== $projection_error) {
            return $projection_error;
        }
        $rights_evidence = $rights_projection['evidence'];
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('public_ids', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('profile_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $profile_result = $signing_profile_id
            ? $this->project_generation_meta($post_id, '_encypher_provenance_signing_profile_id', $signing_profile_id)
            : $this->delete_generation_meta($post_id, '_encypher_provenance_signing_profile_id');
        $projection_error = $this->required_projection_error($post_id, $generation, [$profile_result]);
        if (null !== $projection_error) {
            return $projection_error;
        }

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('sentence_segments', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $embeddings = $text_result['embeddings'] ?? [];
        $sentence_result = $this->persist_sentence_segments(
            $post_id,
            is_array($embeddings) ? $embeddings : []
        );
        $projection_error = $this->required_projection_error($post_id, $generation, [$sentence_result]);
        if (null !== $projection_error) {
            return $projection_error;
        }

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('sign_errors', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $sign_errors_result = $this->delete_generation_meta($post_id, '_encypher_sign_errors');
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            [$sign_errors_result]
        );
        if (null !== $projection_error) {
            return $projection_error;
        }

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('rights_profile_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $rights_profile_version = $settings['rights_profile_version'] ?? '1';
        $final_results = [
            $this->project_generation_meta($post_id, '_encypher_rights_profile_version', sanitize_text_field($rights_profile_version)),
        ];

        if (! $this->owns_text_mutation('final_metadata', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $final_results[] = $this->delete_generation_meta($post_id, '_encypher_needs_marking');
        $final_results[] = $this->delete_generation_meta($post_id, '_encypher_action_type');
        $final_results[] = $this->delete_generation_meta($post_id, '_encypher_provenance_verification');
        $final_results[] = $this->delete_generation_meta($post_id, '_encypher_consecutive_failures');
        $projection_error = $this->required_projection_error($post_id, $generation, $final_results);
        if (null !== $projection_error) {
            return $projection_error;
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        // Featured image already signed as composite ingredient - do NOT call sign_featured_image().

        $this->debug_log(sprintf(
            'Post %d composite-signed via /sign/rich: %d images, %d audios, %d videos. Composite manifest: %s',
            $post_id,
            count($signed_images),
            count($signed_audios),
            count($signed_videos),
            $composite['instance_id'] ?? 'none'
        ));

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        if (! $this->owns_text_mutation('usage', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $settings_for_usage = get_option('encypher_provenance_settings', []);
        $updated_usage = $this->resolve_usage_snapshot($settings_for_usage, true);

        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $response_data = [
            'status'           => $completion_status,
            'signing_method'   => 'rich',
            'composite_manifest' => $composite,
            'usage'            => $updated_usage,
            'media_signed'     => [
                'images' => $images_written,
                'audios' => $audios_written,
                'videos' => $videos_written,
                'total'  => $images_written + $audios_written + $videos_written,
            ],
        ];
        $response_data['document_id'] = $document_id;
        $response_data['verification_url'] = $verification_url;
        $response_data['embedded_content'] = $embedded_content;
        $response_data['total_sentences'] = $total_sentences;
        $response_data['instance_id'] = $instance_id;
        if (null !== $rights_evidence) {
            $response_data = array_merge($response_data, $rights_evidence);
        }


        if (! $this->owns_text_mutation('signed_snapshot', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $projection_error = $this->required_projection_error(
            $post_id,
            $generation,
            $this->project_or_stage_signed_snapshot(
                $post_id,
                $generation,
                $embedded_content,
                $signed_text
            )
        );
        if (null !== $projection_error) {
            return $projection_error;
        }
        if (! $this->has_staged_generation_snapshot($post_id, $generation)) {
            $attempt['previous_signed_content_hash'] = hash('sha256', $signed_text);
        }
        if (! $this->owns_text_attempt($post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }

        if (! $this->owns_text_mutation('commit', $post_id, $generation, $attempt)) {
            return $this->text_generation_fenced_error();
        }
        $terminal = $this->commit_text_generation($post_id, $generation);
        if (! $this->write_result_succeeded($terminal['result'])) {
            return $this->write_result_error($terminal['result']);
        }
        // The parent is now terminal. Do not compensate its attachment source
        // writes if derivative publication itself is interrupted.
        $rich_generation_committed = true;
        $this->publish_rich_attachment_derivatives($rich_media_rollbacks);
        $response_data = $this->add_terminal_cleanup_warning_to_data(
            $response_data,
            $terminal['cleanup']
        );

        return new WP_REST_Response($response_data);
        } finally {
            if (! $rich_generation_committed) {
                $this->rollback_rich_attachments($rich_media_rollbacks);
            }
        }
    }

    /**
     * Return the WordPress/ai content provenance status for a post.
     *
     * Reads the `_encypher_wpai_experiments` post meta stored by the
     * WordPress_AI_Compat layer whenever it signs an AI-generated snippet.
     * Combines that with the overall post signing status to produce a
     * shield-badge-ready status string.
     *
     * Response shape:
     *   { status: 'verified'|'unverified'|'tampered'|'none', details: { experiments: [...] } }
     */
    public function handle_wordpress_ai_status_request(WP_REST_Request $request)
    {
        $post_id = (int) $request->get_param('post_id');

        $experiments = get_post_meta($post_id, '_encypher_wpai_experiments', true);
        if (!is_array($experiments) || empty($experiments)) {
            return new WP_REST_Response([
                'status'  => 'none',
                'details' => ['experiments' => []],
            ]);
        }

        // Derive shield status from the overall post signing state.
        $post_status = get_post_meta($post_id, '_encypher_provenance_status', true);

        if (in_array($post_status, ['c2pa_protected', 'c2pa_verified', 'signed', 'verified'], true)) {
            $shield_status = 'verified';
        } elseif ($post_status === 'tampered') {
            $shield_status = 'tampered';
        } else {
            // Experiments were signed inline but the post hasn't been fully signed yet
            // (e.g. still a draft). Show as unverified rather than none.
            $shield_status = 'unverified';
        }

        return new WP_REST_Response([
            'status'  => $shield_status,
            'details' => [
                'experiments' => array_values($experiments),
            ],
        ]);
    }

    public function handle_status_request(WP_REST_Request $request)
    {
        $post_id = (int) $request->get_param('post_id');
        $generation_pending = '' !== (string) get_post_meta(
            $post_id,
            '_encypher_provenance_generation_pending',
            true
        );
        $status = get_post_meta($post_id, '_encypher_provenance_status', true);
        $signature = get_post_meta($post_id, '_encypher_provenance_signature', true);
        $document_id = get_post_meta($post_id, '_encypher_provenance_document_id', true);
        $instance_id = get_post_meta($post_id, '_encypher_provenance_instance_id', true);
        $total_sentences = (int) get_post_meta($post_id, '_encypher_provenance_total_sentences', true);
        $last_signed = get_post_meta($post_id, '_encypher_provenance_last_signed', true);
        $verification = get_post_meta($post_id, '_encypher_provenance_verification', true);
        $sentences = $this->get_sentence_segments_payload($post_id);
        $settings = get_option('encypher_provenance_settings', []);
        if (!is_array($settings)) {
            $settings = [];
        }
        $tier = isset($settings['tier']) ? (string) $settings['tier'] : 'free';
        $usage = $this->resolve_usage_snapshot($settings, true);

        // Generate verification URL using instance_id if available, otherwise document_id
        $verification_id = $instance_id ?: $document_id;
        $verify_slug = Plugin::get_verify_slug();
        $verification_url = $verification_id ? home_url('/' . $verify_slug . '/') . urlencode($verification_id) : '';

        $now = time();
        $raw_signing_flag = get_post_meta($post_id, '_encypher_signing_in_progress', true);
        $signing_in_progress = false;
        if (! empty($raw_signing_flag)) {
            if (SigningStatus::clear_if_stale($post_id, $raw_signing_flag, $now)) {
                // A deferred signing run died without renewing its lease. Clear
                // only the stale value observed here so a concurrent heartbeat
                // or replacement worker cannot lose its active lease.
                $flag_started_at = SigningStatus::started_at($raw_signing_flag);
                $existing_error = $this->normalize_last_sign_error(get_post_meta($post_id, '_encypher_last_sign_error', true));
                if (null === $existing_error || $existing_error['time'] <= $flag_started_at) {
                    update_post_meta($post_id, '_encypher_last_sign_error', [
                        'code'    => 'signing_stalled',
                        'message' => __('Signing did not complete. Update the post to try again.', 'encypher-provenance'),
                        'time'    => $now,
                    ]);
                }
            } else {
                $signing_in_progress = true;
            }
        }
        $last_sign_error = $this->normalize_last_sign_error(get_post_meta($post_id, '_encypher_last_sign_error', true));

        $status_details = ContentStatus::details_for_post($post_id);
        $canonical_status = (string) ($status_details['display_state'] ?? 'not_signed');
        $publication_assurance = class_exists(PublicationAssurance::class)
            ? PublicationAssurance::projection($post_id)
            : [];

        // Media signing progress and error context (Tasks 4.1, 2.2).
        $sign_errors = get_post_meta($post_id, '_encypher_sign_errors', true);
        $composite_ingredient_count = (int) get_post_meta($post_id, '_encypher_composite_ingredient_count', true);
        $signing_method = get_post_meta($post_id, '_encypher_signing_method', true);
        if ($generation_pending) {
            $status = 'signing';
            $signature = '';
            $document_id = '';
            $instance_id = '';
            $verification_url = '';
            $total_sentences = 0;
            $last_signed = '';
            $verification = null;
            $sentences = ['items' => [], 'total' => 0];
            $sign_errors = [];
            $composite_ingredient_count = 0;
            $signing_method = '';
            $signing_in_progress = true;
        }

        $response = [
            'status' => $canonical_status,
            'provenance_status' => $status ?: 'not_signed',
            'signature' => $signature,
            'document_id' => $document_id,
            'instance_id' => $instance_id,
            'verification_url' => $verification_url,
            'total_sentences' => $total_sentences,
            'last_signed' => $last_signed,
            'verification' => $verification,
            'sentences' => $sentences['items'],
            'sentences_total' => $sentences['total'],
            'usage' => $usage,
            'tier' => $tier,
            'signing_mode' => isset($settings['signing_mode']) ? $settings['signing_mode'] : 'managed',
            'signing_in_progress' => $signing_in_progress,
            'last_sign_error' => $last_sign_error ?: null,
            'provenance_state' => (string) ($status_details['provenance_state'] ?? 'not_signed'),
            'assurance_state' => (string) ($status_details['assurance_state'] ?? 'unknown'),
            'display_state' => $canonical_status,
            'publication_assurance' => $publication_assurance,
        ];

        if ($signing_method === 'rich') {
            $response['signing_progress'] = [
                'media_signed' => $composite_ingredient_count,
            ];
        }

        if (!empty($sign_errors) && is_array($sign_errors)) {
            $response['sign_errors'] = $sign_errors;
            $response['sign_error_count'] = count($sign_errors);
        }

        // Rights profile version for outdated-rights detection (Task 6.3).
        $post_rights_version = get_post_meta($post_id, '_encypher_rights_profile_version', true);
        $current_rights_version = $settings['rights_profile_version'] ?? '1';
        if ($post_rights_version && $post_rights_version !== $current_rights_version) {
            $response['rights_outdated'] = true;
            $response['rights_version_post'] = $post_rights_version;
            $response['rights_version_current'] = $current_rights_version;
        }

        return new WP_REST_Response($response);
    }

    /**
     * Return one attachment's canonical standalone-signing state.
     *
     * A stale in-progress flag means the deferred worker died before cleanup.
     * Resolve it here to an actionable terminal failure so Media Library polls
     * cannot remain on "Signing" forever.
     */
    public function handle_media_status_request(WP_REST_Request $request)
    {
        $attachment_id = (int) $request->get_param('attachment_id');
        if ($attachment_id <= 0 || 'attachment' !== get_post_type($attachment_id)) {
            return new WP_Error('not_found', __('Attachment not found.', 'encypher-provenance'), ['status' => 404]);
        }

        $raw_signing_flag = get_post_meta($attachment_id, '_encypher_signing_in_progress', true);
        $signing_in_progress = false;
        if (! empty($raw_signing_flag)) {
            if (SigningStatus::clear_if_stale($attachment_id, $raw_signing_flag, time())) {
                $this->record_attachment_sign_error(
                    $attachment_id,
                    'signing_stalled',
                    __('Media signing stopped reporting progress. Retry signing this attachment.', 'encypher-provenance')
                );
            } else {
                $signing_in_progress = true;
            }
        }

        $last_error = $this->normalize_last_sign_error(get_post_meta($attachment_id, '_encypher_last_sign_error', true));
        $provenance_status = (string) get_post_meta($attachment_id, '_encypher_provenance_status', true);
        if ($signing_in_progress) {
            $status = 'signing';
        } elseif (null !== $last_error) {
            $status = 'needs_attention';
        } elseif (in_array($provenance_status, VipMediaDelivery::PROVENANCE_STATUSES, true)) {
            $status = 'signed';
        } else {
            $status = 'not_signed';
        }

        return new WP_REST_Response([
            'status'        => $status,
            'attachment_id' => $attachment_id,
            'error'         => $last_error,
            'record_id'     => (string) get_post_meta($attachment_id, '_encypher_image_record_id', true),
            'manifest_url'  => (string) get_post_meta($attachment_id, '_encypher_image_manifest_url', true),
            'public_delivery_state' => (string) get_post_meta($attachment_id, '_encypher_public_delivery_state', true),
        ]);
    }

    /**
     * Return the source asset's stored verification facts and full public-safe
     * C2PA response. The large technical payload is fetched only on demand.
     */
    public function handle_media_provenance_request(WP_REST_Request $request)
    {
        $attachment_id = (int) $request->get_param('attachment_id');
        if ($attachment_id <= 0 || 'attachment' !== get_post_type($attachment_id)) {
            return new WP_Error('not_found', __('Attachment not found.', 'encypher-provenance'), ['status' => 404]);
        }

        $decode = static function (string $meta_key) use ($attachment_id): ?array {
            $raw = get_post_meta($attachment_id, $meta_key, true);
            if (is_array($raw)) {
                return $raw;
            }
            if (! is_string($raw) || '' === $raw) {
                return null;
            }
            $decoded = json_decode($raw, true);
            return is_array($decoded) ? $decoded : null;
        };

        return new WP_REST_Response([
            'attachment_id' => $attachment_id,
            'summary'       => $decode('_encypher_source_c2pa_summary'),
            'rights'        => $decode('_encypher_source_c2pa_rights'),
            'details'       => $decode('_encypher_source_c2pa_details'),
        ]);
    }
    /**
     * Retry the VIP edge purge for one editable attachment. REST cookie auth
     * supplies the nonce; can_edit_attachment() closes cross-owner access.
     */
    public function handle_media_purge_request(WP_REST_Request $request)
    {
        $attachment_id = (int) $request->get_param('attachment_id');
        if ($attachment_id <= 0 || 'attachment' !== get_post_type($attachment_id)) {
            return new WP_Error('not_found', __('Attachment not found.', 'encypher-provenance'), ['status' => 404]);
        }

        $result = VipMediaDelivery::retry_purge($attachment_id);
        if (is_wp_error($result)) {
            $code = $result->get_error_code();
            $status = 502;
            if ('purge_retry_limited' === $code) {
                $status = 429;
            } elseif ('purge_retry_not_failed' === $code) {
                $status = 409;
            }
            return new WP_Error($code, $result->get_error_message(), ['status' => $status]);
        }

        return new WP_REST_Response([
            'status' => 'signed',
            'attachment_id' => $attachment_id,
            'public_delivery_state' => (string) get_post_meta($attachment_id, '_encypher_public_delivery_state', true),
        ]);
    }


    /**
     * Retry signing for a post through the existing deferred-sign queue.
     *
     * Nonce + capability protected (can_edit_post + the REST cookie-auth
     * nonce). Lets the Gutenberg chip and the Classic report reschedule
     * signing without the user republishing or updating the post (PRD T3.2).
     * Reuses the same WP-Cron 'encypher_deferred_sign' path as publish/update,
     * so there is no second signing queue.
     */
    public function handle_retry_request(WP_REST_Request $request)
    {
        $post_id = (int) $request->get_param('post_id');
        $post = get_post($post_id);
        if (! $post || 'trash' === $post->post_status) {
            return new WP_Error('not_found', __('Post not found.', 'encypher-provenance'), ['status' => 404]);
        }

        // A post that already carries provenance re-signs as c2pa.edited; a
        // never-signed post signs fresh.
        $document_id = get_post_meta($post_id, '_encypher_provenance_document_id', true);
        $status = (string) get_post_meta($post_id, '_encypher_provenance_status', true);
        $is_update = ! empty($document_id)
            || in_array($status, ['c2pa_protected', 'c2pa_verified', 'signed', 'verified', 'modified'], true);

        // Do not replace a live worker's lease. A stale value is removed only
        // if it still matches what this request observed.
        $lease = get_post_meta($post_id, '_encypher_signing_in_progress', true);
        if (! empty($lease) && ! SigningStatus::flag_is_stale($lease, time())) {
            return new WP_REST_Response([
                'ok'      => true,
                'status'  => 'signing',
                'post_id' => $post_id,
            ]);
        }
        if (! empty($lease)) {
            SigningStatus::clear_if_stale($post_id, $lease, time());
        }
        delete_post_meta($post_id, '_encypher_last_sign_error');
        $this->request_signing($post_id, $is_update);

        return new WP_REST_Response([
            'ok'      => true,
            'status'  => 'signing',
            'post_id' => $post_id,
        ]);
    }

    /**
     * Execute this post's queued sign after an interactive editor save.
     *
     * WP-Cron remains the fallback for noninteractive saves. Gutenberg calls
     * this endpoint after core confirms the save, avoiding an indefinite
     * Signing state when a host accepts but never dispatches cron loopbacks.
     */
    public function handle_run_pending_sign_request(WP_REST_Request $request)
    {
        $post_id = (int) $request->get_param('post_id');
        $post = get_post($post_id);
        if ($post_id <= 0 || ! $post || 'trash' === $post->post_status) {
            return new WP_Error('not_found', __('Post not found.', 'encypher-provenance'), ['status' => 404]);
        }

        $cron = _get_cron_array();
        if (! is_array($cron)) {
            $cron = [];
        }
        ksort($cron, SORT_NUMERIC);

        foreach ($cron as $timestamp => $hooks) {
            if (! isset($hooks['encypher_deferred_sign']) || ! is_array($hooks['encypher_deferred_sign'])) {
                continue;
            }
            foreach ($hooks['encypher_deferred_sign'] as $event) {
                $args = isset($event['args']) && is_array($event['args']) ? $event['args'] : [];
                if ((int) ($args[0] ?? 0) !== $post_id || 'attachment' === (string) ($args[3] ?? 'post')) {
                    continue;
                }

                if (! wp_unschedule_event((int) $timestamp, 'encypher_deferred_sign', $args)) {
                    return new WP_Error('queue_changed', __('The signing queue changed. Please save again.', 'encypher-provenance'), ['status' => 409]);
                }

                $this->handle_deferred_sign(
                    $post_id,
                    (bool) ($args[1] ?? false),
                    (int) ($args[2] ?? 0),
                    (string) ($args[3] ?? 'post')
                );

                return new WP_REST_Response([
                    'processed' => true,
                    'post_id' => $post_id,
                ]);
            }
        }

        return new WP_REST_Response([
            'processed' => false,
            'post_id' => $post_id,
        ]);
    }

    /**
     * Verify the original bytes of a provenance-bearing media attachment.
     *
     * The same not-found response covers invalid IDs, non-attachments, and
     * unsigned media so the public reader endpoint does not expose private
     * attachment state.
     */
    public function handle_verify_media_request(WP_REST_Request $request)
    {
        $post_id       = absint($request->get_param('post_id'));
        $attachment_id = absint($request->get_param('attachment_id'));
        if (
            $post_id <= 0
            || $attachment_id <= 0
            || 'attachment' !== get_post_type($attachment_id)
            || ! VipMediaDelivery::carries_provenance($attachment_id)
        ) {
            return new WP_Error(
                'media_not_signed',
                __('Signed media not found.', 'encypher-provenance'),
                ['status' => 404]
            );
        }

        $public_url = $this->resolve_public_media_url($post_id, $attachment_id);
        if (is_wp_error($public_url)) {
            return $public_url;
        }

        $mime_type = get_post_mime_type($attachment_id);
        if (! is_string($mime_type) || '' === $mime_type) {
            return new WP_Error(
                'media_unreadable',
                __('Signed media could not be read.', 'encypher-provenance'),
                ['status' => 404]
            );
        }

        $url_path = (string) wp_parse_url($public_url, PHP_URL_PATH);
        $filename = sanitize_file_name(basename($url_path));
        $result   = $this->verify_media_url($public_url, $mime_type, $filename, $attachment_id);
        if (is_wp_error($result)) {
            return $result;
        }

        return new WP_REST_Response($result);
    }


    /**
     * Resolve the exact signed-media URL rendered by a public post.
     *
     * The post binding prevents anonymous attachment-ID probes from causing
     * reads of draft, private, password-protected, or unrelated media.
     *
     * @return string|WP_Error
     */
    public function resolve_public_media_url(int $post_id, int $attachment_id)
    {
        $not_found = static fn (): WP_Error => new WP_Error(
            'media_not_signed',
            __('Signed media not found.', 'encypher-provenance'),
            ['status' => 404]
        );
        foreach (VipMediaDelivery::public_signed_media($post_id) as $item) {
            if ((int) ($item['id'] ?? 0) === $attachment_id) {
                return (string) ($item['url'] ?? '');
            }
        }

        return $not_found();
    }

    public function handle_verify_request(WP_REST_Request $request)
    {
        $post_id = (int) $request->get_param('post_id');
        $post = get_post($post_id);
        if (! $post) {
            return new WP_Error('invalid_post', __('Invalid post.', 'encypher-provenance'), ['status' => 400]);
        }

        if ('publish' !== $post->post_status) {
            return new WP_Error('invalid_post_status', __('Post not found or not published.', 'encypher-provenance'), ['status' => 404]);
        }

        if (
            ! function_exists('is_post_publicly_viewable')
            || ! is_post_publicly_viewable($post)
            || ! empty($post->post_password)
        ) {
            return new WP_Error('invalid_post_status', __('Post not found or not published.', 'encypher-provenance'), ['status' => 404]);
        }


        // A rich-sign response may contain valid signed text while one or more
        // requested media assets failed. The post is incomplete as a whole.
        // Never let a public text-only verification promote that partial run
        // to c2pa_verified or conceal the media failure.
        $signing_status = (string) get_post_meta($post_id, '_encypher_provenance_status', true);
        $sign_errors = get_post_meta($post_id, '_encypher_sign_errors', true);
        $signing_method = (string) get_post_meta($post_id, '_encypher_signing_method', true);
        $rich_media_stale = 'rich' === $signing_method && ! VipMediaDelivery::post_media_intact($post_id);
        if (
            in_array($signing_status, ['embedding_failed', 'media_signing_failed'], true)
            || $rich_media_stale
            || (is_array($sign_errors) && ! empty($sign_errors))
        ) {
            return new WP_Error(
                'signing_incomplete',
                __('Provenance verification is unavailable because the last signing run did not protect every requested item.', 'encypher-provenance'),
                ['status' => 409]
            );
        }

        if (! RslExport::has_current_committed_text_provenance($post_id)) {
            return new WP_Error(
                'provenance_not_current',
                __('Current content does not have a committed text-provenance snapshot.', 'encypher-provenance'),
                ['status' => 409]
            );
        }

        // WS1: post_content is now stored CLEAN (markers injected only at
        // render). The marker-bearing signed text lives in the side store
        // `_encypher_signed_text`. Verify against that so the C2PA hard binding
        // (computed over the canonical visible text WITH the wrapper present)
        // matches. Fall back to raw post_content for posts signed before WS1
        // (which still carry markers inline).
        $signed_text_meta = get_post_meta($post_id, '_encypher_signed_text', true);
        if (is_string($signed_text_meta) && '' !== $signed_text_meta) {
            $verify_source = $signed_text_meta;
        } else {
            $verify_source = get_post_field('post_content', $post_id, 'raw');
        }

        // Extract plain text from HTML for verification, preserving invisible
        // VS markers (extract_text_for_verify works at the byte level).
        $verify_text = $this->extract_text_for_verify($verify_source);

        // /public/verify: the only public text-verify path on the current API
        // (legacy /verify removed upstream; production serves both).
        $endpoint = '/public/verify';
        $require_auth = false;

        // Verify-result transient cache keyed by the canonical content hash plus
        // the API base URL: repeat badge clicks are served locally and the cold
        // verify cost is paid once per content version. Implicitly invalidated
        // when the content changes (its hash changes). Disabled under WP_DEBUG.
        $verify_cache_settings = get_option('encypher_provenance_settings', []);
        $verify_api_base = isset($verify_cache_settings['api_base_url']) ? rtrim((string) $verify_cache_settings['api_base_url'], '/') : '';
        $cache_enabled = !defined('WP_DEBUG') || !WP_DEBUG;
        $cache_key = 'encypher_verify_' . $post_id . '_' . md5($verify_text . '|' . $endpoint . '|' . $verify_api_base);

        if ($cache_enabled) {
            $cached = get_transient($cache_key);
            if (false !== $cached && is_array($cached)) {
                $this->debug_log(sprintf('Returning cached verification for post %d', $post_id));
                $cached['cached'] = true;
                return new WP_REST_Response($cached);
            }
        } else {
            $this->debug_log(sprintf('Cache disabled (WP_DEBUG=true), performing fresh verification for post %d', $post_id));
        }

        $payload = [
            'text' => $verify_text,
        ];

        $verification_mode = 'standard';
        $this->debug_log(sprintf('Calling %s for post %d (content length: %d)', $endpoint, $post_id, strlen($verify_source)));
        $response = $this->call_backend($endpoint, $payload, $require_auth, 10); // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- rate-limited public REST XHR triggered by an explicit badge click, never page-render blocking; bounded by the per-IP limiter and served from a 10-minute transient after the first cold verify.
        if (is_wp_error($response)) {
            $this->debug_log(sprintf('Verification API error for post %d: %s', $post_id, $response->get_error_message()));
            delete_post_meta($post_id, '_encypher_provenance_last_verified');
            update_post_meta($post_id, '_encypher_provenance_status', 'verification_failed');
            update_post_meta($post_id, '_encypher_provenance_verification', [
                'valid' => false,
                'error' => $response->get_error_message(),
            ]);
            $this->record_verify_counters([], 0);
            return $response;
        }

        $normalized = [
            'valid' => false,
            'tampered' => false,
            'reason_code' => null,
            'signer_id' => null,
            'signer_name' => null,
            'signing_identity' => null,
            'trust_level' => '',
            'ca_backed' => false,
            'trusted_signer' => false,
            'verified_at' => null,
            'metadata' => null,
            'error' => null,
            'cached' => false,
            'verification_mode' => $verification_mode,
            'correlation_id' => null,
            'attribution' => null,
            'plagiarism' => null,
            'fuzzy_search' => null,
        ];

        $normalized = $this->normalize_verify_verdict($response, $normalized);

        $this->debug_log(sprintf('Verification response for post %d: valid=%s', $post_id, $normalized['valid'] ? 'true' : 'false'));

        // Cache the verification result for 10 minutes (only when caching is enabled).
        if ($cache_enabled) {
            set_transient($cache_key, $normalized, 10 * MINUTE_IN_SECONDS);
        }

        // Store verification result in post meta
        update_post_meta($post_id, '_encypher_provenance_verification', $normalized);
        if (! empty($normalized['valid'])) {
            update_post_meta($post_id, '_encypher_provenance_last_verified', current_time('mysql'));
        } else {
            delete_post_meta($post_id, '_encypher_provenance_last_verified');
        }

        // Store instance_id for public provenance lookup
        if (!empty($normalized['metadata']['instance_id'])) {
            update_post_meta($post_id, '_encypher_provenance_instance_id', $normalized['metadata']['instance_id']);
        }

        // Update status based on verification. A no-marker result on content
        // that was never signed is NOT a verification failure - keep it
        // not_signed so unsigned posts are not stamped 'verification_failed'.
        $status = 'not_signed';
        if (!empty($normalized['valid'])) {
            $status = 'c2pa_verified';
        } elseif (!empty($normalized['error']) && empty($normalized['no_markers'])) {
            $status = 'verification_failed';
        }
        update_post_meta($post_id, '_encypher_provenance_status', $status);

        $normalized['cached'] = false;

        $verify_settings = get_option('encypher_provenance_settings', []);
        if (is_array($verify_settings)) {
            $verify_api_base_url = isset($verify_settings['api_base_url']) ? (string) $verify_settings['api_base_url'] : '';
            $verify_api_key = isset($verify_settings['api_key']) ? (string) $verify_settings['api_key'] : '';
            $this->sync_remote_wordpress_verification_event($verify_api_base_url, $verify_api_key, [
                'install_id' => $this->ensure_local_install_id($verify_settings),
                'post_id' => $post_id,
                'post_url' => get_permalink($post_id),
                'valid' => ! empty($normalized['valid']),
                'tampered' => ! empty($normalized['tampered']),
                'status' => $status,
                'verified_at' => gmdate('c'),
            ]);
        }

        $this->record_verify_counters($normalized, $post_id);

        // Privacy-safe external-referrer signal: record only the referring
        // DOMAIN (never IPs, paths, or query strings) in a bounded option.
        $this->record_external_referrer();

        return new WP_REST_Response($normalized);
    }

    /**
     * Normalise an Enterprise API text-verify response into the plugin's flat
     * verdict shape.
     *
     * The current Enterprise API serves the verdict FLAT at the top level of
     * /public/verify (valid / signer / tampered / reason_code / signed_at et
     * al.); older builds (the legacy /verify route, since removed upstream)
     * wrapped the SAME verdict under a `data` envelope. Accept BOTH shapes so a
     * reader badge verifies against every deployed and future API -- otherwise
     * the switch to /public/verify (which returns no `data` key) makes every
     * verdict read as valid=false. Envelope-level extras (attribution /
     * plagiarism / fuzzy_search / correlation_id) stay top-level in both shapes.
     *
     * Pure (no I/O) so the verdict contract is unit-testable in isolation.
     *
     * @param array $response   Decoded verify API response.
     * @param array $normalized Pre-seeded normalized verdict (defaults applied).
     * @return array The populated normalized verdict.
     */
    private function normalize_verify_verdict(array $response, array $normalized): array
    {
        $reader_payload = (isset($response['data']) && is_array($response['data']))
            ? $response['data']
            : $response;
        if (isset($reader_payload['reader_verdict']) && is_array($reader_payload['reader_verdict'])) {
            // The API owns this wire contract. Preserve it byte-for-byte at the
            // field level; ReaderVerdict validates and maps it for consumers.
            $normalized['reader_verdict'] = $reader_payload['reader_verdict'];
        }
        if (isset($response['success']) && $response['success']) {
            $verdict = (isset($response['data']) && is_array($response['data'])) ? $response['data'] : $response;
            $normalized['valid'] = !empty($verdict['valid']);
            $normalized['tampered'] = !empty($verdict['tampered']);
            $normalized['reason_code'] = isset($verdict['reason_code']) ? (string) $verdict['reason_code'] : null;
            $signer_trust = self::normalize_signer_trust_tuple($verdict);
            $normalized['signer_id'] = $signer_trust['signer_id'];
            $normalized['signer_name'] = $signer_trust['signer_name'];
            $normalized['signing_identity'] = $signer_trust['signer_name'];
            $normalized['trust_level'] = $signer_trust['trust_level'];
            $normalized['ca_backed'] = $signer_trust['ca_backed'];
            $normalized['trusted_signer'] = $signer_trust['trusted_signer'];
            $normalized['verified_at'] = isset($verdict['signed_at']) ? (string) $verdict['signed_at'] : null;
            $normalized['metadata'] = $this->build_manifest_payload_for_display($verdict);
            if (isset($response['correlation_id'])) {
                $normalized['correlation_id'] = (string) $response['correlation_id'];
            }
            if (isset($response['attribution']) && is_array($response['attribution'])) {
                $normalized['attribution'] = $response['attribution'];
            }
            if (isset($response['plagiarism']) && is_array($response['plagiarism'])) {
                $normalized['plagiarism'] = $response['plagiarism'];
            }
            if (isset($response['fuzzy_search']) && is_array($response['fuzzy_search'])) {
                $normalized['fuzzy_search'] = $response['fuzzy_search'];
            }
            if (!$normalized['valid']) {
                // Branch on WHY: claiming "manifest found" for marker-less
                // content (backend SIGNER_UNKNOWN / no metadata) is wrong and
                // confused unsigned-post verification with real failures.
                $no_markers = in_array((string) $normalized['reason_code'], ['SIGNER_UNKNOWN', 'NO_MANIFEST', 'NOT_FOUND'], true)
                    && empty($normalized['metadata']);
                if ($normalized['tampered']) {
                    $normalized['error'] = __('Manifest found but signature verification failed. The content may have been modified.', 'encypher-provenance');
                } elseif ($no_markers) {
                    $normalized['error'] = __('No C2PA provenance markers were found in this content.', 'encypher-provenance');
                    $normalized['no_markers'] = true;
                } else {
                    $normalized['error'] = __('Manifest found but could not be verified.', 'encypher-provenance');
                }
            }
        } elseif (isset($response['error']) && is_array($response['error']) && isset($response['error']['message'])) {
            $normalized['error'] = (string) $response['error']['message'];
        }

        return $normalized;
    }

    /**
     * Count one canonical terminal reader verdict. Legacy hit counters remain
     * success-only while the outcome maps retain failed and unavailable checks.
     */
    private function record_verify_counters(array $payload, int $post_id): string
    {
        $state = ReaderVerdict::state_from_payload($payload);
        $this->increment_verify_outcome($state);
        if (ReaderVerdict::is_success_state($state) || 'valid_untrusted' === $state) {
            $this->increment_verify_hits($post_id);
        }
        return $state;
    }

    /**
     * Record every terminal reader outcome separately from the legacy public
     * success counters. The frozen map shape is stable for reporting.
     */
    private function increment_verify_outcome(string $state): void
    {
        if (! in_array($state, ReaderVerdict::STATES, true)) {
            return;
        }
        foreach ([
            'encypher_verify_outcomes',
            'encypher_verify_outcomes_' . gmdate('Y-m-d'),
        ] as $option_name) {
            $this->atomic_increment_outcome_map($option_name, $state);
        }
    }

    /**
     * Atomically replace one serialized outcome map using its exact prior
     * bytes as the compare-and-swap token. Retrying a lost CAS preserves every
     * concurrent terminal verdict while retaining the frozen option-map shape.
     */
    private function atomic_increment_outcome_map(string $option_name, string $state): void
    {
        global $wpdb;
        if (
            ! is_object($wpdb)
            || ! method_exists($wpdb, 'query')
            || ! method_exists($wpdb, 'prepare')
            || ! method_exists($wpdb, 'get_var')
        ) {
            // Narrow non-WordPress test shims have no concurrent requests.
            $outcomes = get_option($option_name, []);
            $outcomes = is_array($outcomes) ? $outcomes : [];
            $outcomes = array_merge(array_fill_keys(ReaderVerdict::STATES, 0), $outcomes);
            $outcomes[$state] = (int) ($outcomes[$state] ?? 0) + 1;
            update_option($option_name, $outcomes, false);
            return;
        }

        while (true) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- raw serialized bytes are the CAS token; the option cache is invalidated after the write.
            $raw = $wpdb->get_var($wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
                $option_name
            ));
            if (null === $raw) {
                $outcomes = array_fill_keys(ReaderVerdict::STATES, 0);
                $outcomes[$state] = 1;
                $initial = function_exists('maybe_serialize')
                    ? maybe_serialize($outcomes)
                    : serialize($outcomes); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- standalone fallback serializes a fixed scalar counter map, never objects or request data.
                // INSERT IGNORE closes the first-writer race. A loser loops and
                // increments the exact map installed by the winner.
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic first write; cache invalidated below.
                $inserted = $wpdb->query($wpdb->prepare(
                    "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
                    $option_name,
                    $initial
                ));
                if (false === $inserted) {
                    return;
                }
                if ((int) $inserted > 0) {
                    $this->flush_option_object_cache($option_name);
                    return;
                }
                continue;
            }

            $outcomes = function_exists('maybe_unserialize')
                ? maybe_unserialize($raw)
                : @unserialize((string) $raw, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- standalone fallback reads the plugin-owned scalar counter map with object construction disabled.
            $outcomes = is_array($outcomes) ? $outcomes : [];
            $outcomes = array_merge(array_fill_keys(ReaderVerdict::STATES, 0), $outcomes);
            $outcomes[$state] = (int) ($outcomes[$state] ?? 0) + 1;
            $replacement = function_exists('maybe_serialize')
                ? maybe_serialize($outcomes)
                : serialize($outcomes); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- standalone fallback serializes a fixed scalar counter map, never objects or request data.

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- exact-value CAS prevents lost outcome increments; cache invalidated below.
            $updated = $wpdb->query($wpdb->prepare(
                "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s",
                $replacement,
                $option_name,
                (string) $raw
            ));
            if (false === $updated) {
                return;
            }
            if ((int) $updated > 0) {
                $this->flush_option_object_cache($option_name);
                return;
            }
        }
    }

    /**
     * Increment the site-wide verification counters and the current per-day
     * bucket, plus the per-post lifetime counter for the verified post.
     *
     * Storage (v2.5.0): the lifetime total (encypher_verify_hits) and each
     * day's bucket (encypher_verify_hits_day_Ymd) are single-row SQL
     * increments, not get_option/update_option read-modify-write, so
     * concurrent public verifies never lose an update. Per-day options are
     * autoload-off and pruned opportunistically past 30 days. The legacy
     * encypher_verify_hits_daily array is intentionally NOT written anymore;
     * the read helper (Admin::verification_daily_map) still merges it so no
     * historical count is lost.
     *
     * @param int $post_id Post whose per-post counter to bump (0 to skip).
     */
    private function increment_verify_hits(int $post_id = 0): void
    {
        $this->atomic_increment_option('encypher_verify_hits');
        $created_today = $this->atomic_increment_option('encypher_verify_hits_day_' . gmdate('Ymd'));

        // Prune only when today's bucket was just created (the first verify of
        // the day), so the LIKE scan runs at most once per day, not per hit.
        if ($created_today) {
            $this->prune_daily_verify_options();
        }

        // Per-post lifetime verify count (atomic; approximate under races is
        // acceptable, a lost update is not).
        if ($post_id > 0) {
            $this->atomic_increment_postmeta($post_id, '_encypher_verify_hits');
        }
    }

    /**
     * Atomically increment an integer wp_options row by one, inserting it with
     * autoload off when absent. Returns true when a new row was inserted (the
     * first-ever increment for that option), false when an existing row was
     * bumped.
     *
     * A single UPDATE ... option_value = option_value + 1 avoids the
     * lost-update window of a get_option()/update_option() read-modify-write
     * under concurrent verifies; the object cache is dropped afterward so
     * get_option() stays coherent with the direct write.
     */
    private function atomic_increment_option(string $option_name): bool
    {
        global $wpdb;
        if (! is_object($wpdb) || ! method_exists($wpdb, 'query') || ! method_exists($wpdb, 'prepare')) {
            // Non-WP context (some unit shims): degrade to a plain increment.
            $existing = get_option($option_name, false);
            update_option($option_name, (int) $existing + 1, false);
            return false === $existing;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic single-row counter bump; a read-modify-write drops updates under concurrent public verifies. Cache is invalidated below.
        $updated = (int) $wpdb->query($wpdb->prepare(
            "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s",
            $option_name
        ));

        $inserted = false;
        if ($updated < 1) {
            // Row absent: insert autoload-off, tolerating the duplicate-key
            // race between two first-ever verifies (ON DUPLICATE bumps instead).
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above; ON DUPLICATE KEY closes the concurrent first-write race.
            $wpdb->query($wpdb->prepare(
                "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no') ON DUPLICATE KEY UPDATE option_value = option_value + 1",
                $option_name,
                '1'
            ));
            $inserted = true;
        }

        $this->flush_option_object_cache($option_name);
        return $inserted;
    }

    /**
     * Increment one per-post counter while holding a database advisory lock.
     *
     * wp_postmeta has no unique constraint on (post_id, meta_key), so an
     * UPDATE-then-INSERT first write can create two rows. The advisory lock
     * serializes the first write across PHP workers. Existing duplicate rows
     * are folded into the lowest meta_id by summing their non-negative values,
     * then removed before the increment returns.
     */
    private function atomic_increment_postmeta(int $post_id, string $meta_key): void
    {
        global $wpdb;
        if (
            ! is_object($wpdb)
            || ! method_exists($wpdb, 'query')
            || ! method_exists($wpdb, 'prepare')
            || ! method_exists($wpdb, 'get_var')
            || ! method_exists($wpdb, 'get_results')
        ) {
            $current = (int) get_post_meta($post_id, $meta_key, true);
            update_post_meta($post_id, $meta_key, $current + 1);
            return;
        }

        $blog_id = function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
        $lock_name = sprintf(
            'encypher:verify:%d:%d:%s',
            $blog_id,
            $post_id,
            substr(hash('sha256', $meta_key), 0, 12)
        );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the advisory lock supplies uniqueness absent from wp_postmeta.
        $acquired = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 5));
        if ('1' !== (string) $acquired) {
            return;
        }

        $transaction_started = false;
        $transaction_committed = false;
        try {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- duplicate consolidation must commit as one unit.
            if (false === $wpdb->query('START TRANSACTION')) {
                return;
            }
            $transaction_started = true;
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- rows are read only while the per-post advisory lock is held.
            $rows = $wpdb->get_results($wpdb->prepare(
                "SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC FOR UPDATE",
                $post_id,
                $meta_key
            ));
            if ($this->wpdb_query_failed($wpdb, $rows)) {
                return;
            }
            $rows = is_array($rows) ? $rows : [];
            if (empty($rows)) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- first write is serialized by GET_LOCK.
                $inserted = $wpdb->query($wpdb->prepare(
                    "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES (%d, %s, %s)",
                    $post_id,
                    $meta_key,
                    '1'
                ));
                if (false === $inserted || (int) $inserted < 1) {
                    return;
                }
            } else {
                $authoritative_id = 0;
                $count = 0;
                foreach ($rows as $row) {
                    $meta_id = is_object($row)
                        ? (int) ($row->meta_id ?? 0)
                        : (int) ($row['meta_id'] ?? 0);
                    $meta_value = is_object($row)
                        ? ($row->meta_value ?? 0)
                        : ($row['meta_value'] ?? 0);
                    if (0 === $authoritative_id && $meta_id > 0) {
                        $authoritative_id = $meta_id;
                    }
                    $count += max(0, (int) $meta_value);
                }
                if (0 === $authoritative_id) {
                    return;
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- deterministic consolidation and increment under the advisory lock.
                $updated = $wpdb->query($wpdb->prepare(
                    "UPDATE {$wpdb->postmeta} SET meta_value = %s WHERE meta_id = %d",
                    (string) ($count + 1),
                    $authoritative_id
                ));
                if (false === $updated || (int) $updated < 1) {
                    return;
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- remove legacy duplicates after their values have been folded into the authoritative row.
                $deleted = $wpdb->query($wpdb->prepare(
                    "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id <> %d",
                    $post_id,
                    $meta_key,
                    $authoritative_id
                ));
                if (false === $deleted) {
                    return;
                }
            }
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- publish the single authoritative counter row.
            if (false === $wpdb->query('COMMIT')) {
                return;
            }
            $transaction_committed = true;
        } finally {
            if ($transaction_started && ! $transaction_committed) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- discard any partial consolidation.
                $wpdb->query('ROLLBACK');
            }
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- release the exact advisory lock acquired above.
            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
        }
        if (function_exists('wp_cache_delete')) {
            wp_cache_delete($post_id, 'post_meta');
        }
    }

    /**
     * Drop the object-cache entries so get_option() reflects a direct
     * wp_options write (the row, the not-found marker, and the autoload blob).
     */
    private function flush_option_object_cache(string $option_name): void
    {
        if (! function_exists('wp_cache_delete')) {
            return;
        }
        wp_cache_delete($option_name, 'options');
        wp_cache_delete('alloptions', 'options');
        if (function_exists('wp_cache_get') && function_exists('wp_cache_set')) {
            $notoptions = wp_cache_get('notoptions', 'options');
            if (is_array($notoptions) && isset($notoptions[$option_name])) {
                unset($notoptions[$option_name]);
                wp_cache_set('notoptions', $notoptions, 'options');
            }
        }
    }

    /**
     * Delete per-day verification bucket options older than 30 days. Bounded:
     * a single indexed LIKE scan of the option names, then delete_option() for
     * each stale key. Called at most once per day (see increment_verify_hits).
     */
    private function prune_daily_verify_options(): void
    {
        global $wpdb;
        if (! is_object($wpdb) || ! method_exists($wpdb, 'get_col') || ! method_exists($wpdb, 'prepare')) {
            return;
        }
        $cutoff = gmdate('Ymd', time() - (30 * 86400));
        $like = (method_exists($wpdb, 'esc_like') ? $wpdb->esc_like('encypher_verify_hits_day_') : 'encypher_verify_hits_day_') . '%';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- once-a-day prune of per-day counter option names; not cacheable.
        $names = $wpdb->get_col($wpdb->prepare(
            "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
            $like
        ));
        if (! is_array($names)) {
            return;
        }
        foreach ($names as $name) {
            $suffix = substr((string) $name, strlen('encypher_verify_hits_day_'));
            if (1 === preg_match('/^\d{8}$/', $suffix) && $suffix < $cutoff) {
                delete_option((string) $name);
            }
        }
    }

    /**
     * Maximum distinct external referrer domains retained in the bounded map.
     */
    private const MAX_EXTERNAL_REFERRERS = 50;

    /**
     * Record a privacy-safe external-referrer signal for a public verify.
     *
     * Reads the Referer header and, when its host differs from this site's
     * host, increments a per-DOMAIN counter in the bounded
     * encypher_verify_external_referrers option. NEVER stores IPs, URL paths,
     * or query strings - only the bare hostname. The map is capped at
     * MAX_EXTERNAL_REFERRERS domains; when full, the smallest-count domain is
     * evicted to make room for a new one.
     */
    private function record_external_referrer(): void
    {
        $referer = isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : '';
        if ('' === $referer) {
            return;
        }

        $host = wp_parse_url($referer, PHP_URL_HOST);
        if (! is_string($host) || '' === $host) {
            return;
        }
        $host = strtolower($host);
        // Hostname characters only: reject anything that is not a plain domain
        // so no path/query/credential fragment can ever be persisted.
        if (1 !== preg_match('/^[a-z0-9.\-]+$/', $host) || false !== filter_var($host, FILTER_VALIDATE_IP)) {
            return;
        }

        $site_host = strtolower((string) (wp_parse_url(home_url('/'), PHP_URL_HOST) ?: ''));
        if ('' === $site_host || $host === $site_host) {
            return;
        }

        $map = get_option('encypher_verify_external_referrers', []);
        if (! is_array($map)) {
            $map = [];
        }

        if (isset($map[$host])) {
            $map[$host] = (int) $map[$host] + 1;
        } elseif (count($map) >= self::MAX_EXTERNAL_REFERRERS) {
            // Full and this is a new domain: evict the smallest counter so the
            // map stays bounded rather than growing without limit.
            asort($map);
            array_shift($map);
            $map[$host] = 1;
        } else {
            $map[$host] = 1;
        }

        update_option('encypher_verify_external_referrers', $map, false);
    }

    /**
     * Normalize signer identity and trust into the shared verifier contract used
     * by text and media responses.
     *
     * @return array{signer_id:?string,signer_name:?string,trust_level:string,ca_backed:bool,trusted_signer:bool}
     */
    private static function normalize_signer_trust_tuple(array $verdict): array
    {
        $signer = isset($verdict['signer']) && is_array($verdict['signer']) ? $verdict['signer'] : [];
        $manifest_signer = [];
        $manifest = isset($verdict['c2pa_manifest']) && is_array($verdict['c2pa_manifest'])
            ? $verdict['c2pa_manifest']
            : [];
        $active_id = isset($manifest['active_manifest']) ? (string) $manifest['active_manifest'] : '';
        if (
            '' !== $active_id
            && isset($manifest['manifests'][$active_id]['signer'])
            && is_array($manifest['manifests'][$active_id]['signer'])
        ) {
            $manifest_signer = $manifest['manifests'][$active_id]['signer'];
        }

        $signer_id = self::first_non_empty_string([
            $signer['signer_id'] ?? null,
            $verdict['signer_id'] ?? null,
            $manifest_signer['signer_id'] ?? null,
            $manifest_signer['id'] ?? null,
        ]);
        $signer_name = self::resolve_signing_identity($verdict)
            ?: self::resolve_signer_display($verdict)
            ?: self::first_non_empty_string([
                $manifest_signer['organization_name'] ?? null,
                $manifest_signer['name'] ?? null,
            ]);
        $trust_level = self::first_non_empty_string([
            $verdict['trust_level'] ?? null,
            $signer['trust_level'] ?? null,
            $manifest_signer['trust_level'] ?? null,
        ]) ?? '';
        $raw_ca_backed = $verdict['ca_backed']
            ?? $signer['ca_backed']
            ?? $manifest_signer['ca_backed']
            ?? false;
        $ca_backed = self::truthy($raw_ca_backed);
        $raw_trusted_signer = $verdict['trusted_signer']
            ?? $signer['trusted_signer']
            ?? $manifest_signer['trusted_signer']
            ?? null;
        $reason_code = strtoupper((string) ($verdict['reason_code'] ?? ''));
        $explicitly_untrusted = 'UNTRUSTED_SIGNER' === $reason_code
            || in_array(strtolower($trust_level), ['untrusted', 'unverified'], true);
        $trusted_signer = $explicitly_untrusted
            ? false
            : (null !== $raw_trusted_signer
                ? self::truthy($raw_trusted_signer)
                : ($ca_backed || in_array(strtolower($trust_level), ['trusted', 'verified_trusted', 'ca_backed', 'ca-backed'], true)));

        return [
            'signer_id'      => $signer_id,
            'signer_name'    => $signer_name,
            'trust_level'    => $trust_level,
            'ca_backed'      => $ca_backed,
            'trusted_signer' => $trusted_signer,
        ];
    }

    private static function truthy($value): bool
    {
        if (is_bool($value)) {
            return $value;
        }
        if (is_int($value) || is_float($value)) {
            return 0 !== (int) $value;
        }
        if (is_string($value)) {
            return in_array(strtolower(trim($value)), ['1', 'true', 'yes'], true);
        }

        return false;
    }

    /**
     * @param array<int,mixed> $candidates
     */
    private static function first_non_empty_string(array $candidates): ?string
    {
        foreach ($candidates as $candidate) {
            if ((is_string($candidate) || is_numeric($candidate)) && '' !== trim((string) $candidate)) {
                return trim((string) $candidate);
            }
        }

        return null;
    }

    /**
     * Resolve signing identity for UI display.
     *
     * Prefer the claimed publisher/organization fields over tool names so a
     * valid-but-untrusted self-signed manifest cannot appear as Encypher-signed.
     */
    private static function resolve_signing_identity(array $verdict): ?string
    {
        $signer = isset($verdict['signer']) && is_array($verdict['signer']) ? $verdict['signer'] : [];
        $candidates = [
            $verdict['publisher_display_name'] ?? null,
            $signer['organization_name'] ?? null,
            $verdict['signer_name'] ?? null,
            $verdict['signing_identity'] ?? null,
            $signer['name'] ?? null,
        ];

        $c2pa_manifest = isset($verdict['c2pa_manifest']) && is_array($verdict['c2pa_manifest'])
            ? $verdict['c2pa_manifest']
            : null;

        if (isset($c2pa_manifest['assertions']) && is_array($c2pa_manifest['assertions'])) {
            foreach ($c2pa_manifest['assertions'] as $assertion) {
                if (!is_array($assertion) || ($assertion['label'] ?? null) !== 'c2pa.metadata' || !isset($assertion['data']) || !is_array($assertion['data'])) {
                    continue;
                }
                $meta = $assertion['data'];
                if (isset($meta['publisher']) && is_array($meta['publisher'])) {
                    $candidates[] = $meta['publisher']['name'] ?? null;
                    $candidates[] = $meta['publisher']['identifier'] ?? null;
                }
                $candidates[] = $meta['author'] ?? null;
            }
        }

        return self::first_non_empty_string($candidates);
    }

    /**
     * Resolve signer display label when no signing identity is available.
     */
    private static function resolve_signer_display(array $verdict): ?string
    {
        $signer = isset($verdict['signer']) && is_array($verdict['signer']) ? $verdict['signer'] : [];

        return self::first_non_empty_string([
            $verdict['publisher_display_name'] ?? null,
            $signer['organization_name'] ?? null,
            $verdict['signer_name'] ?? null,
            $signer['name'] ?? null,
            $signer['signer_id'] ?? null,
            $verdict['signer_id'] ?? null,
        ]);
    }

    /**
     * Build a display-friendly manifest payload for the frontend modal.
     *
     * Prefer the full DB-backed C2PA assertion set when available, then merge
     * verification details (segment_uuid / total_signatures) for UX summaries.
     */
    private function build_manifest_payload_for_display(array $verdict): ?array
    {
        $manifest = [];

        $c2pa_source = isset($verdict['c2pa_manifest']) && is_array($verdict['c2pa_manifest'])
            ? $verdict['c2pa_manifest']
            : null;

        if ($c2pa_source !== null) {
            if (isset($c2pa_source['assertions']) && is_array($c2pa_source['assertions']) && !empty($c2pa_source['assertions'])) {
                $manifest['assertions'] = $c2pa_source['assertions'];
            }
            foreach (['manifest_url', 'manifest_hash', 'validated', 'validation_type', 'certificate_chain'] as $key) {
                if (array_key_exists($key, $c2pa_source) && null !== $c2pa_source[$key]) {
                    $manifest[$key] = $c2pa_source[$key];
                }
            }
            // Surface c2pa_manifest_valid when present
            if (isset($verdict['c2pa_manifest_valid'])) {
                $manifest['validated'] = (bool) $verdict['c2pa_manifest_valid'];
            }
        }

        if (isset($verdict['details']) && is_array($verdict['details']) && isset($verdict['details']['manifest']) && is_array($verdict['details']['manifest'])) {
            foreach ($verdict['details']['manifest'] as $key => $value) {
                if (!array_key_exists($key, $manifest)) {
                    $manifest[$key] = $value;
                }
            }
        }

        return !empty($manifest) ? $manifest : null;
    }

    public function handle_provenance_request(WP_REST_Request $request)
    {
        // Support lookup by instance_id or post_id
        $instance_id = $request->get_param('instance_id');
        $post_id = $request->get_param('post_id');

        if ($instance_id) {
            // Query posts by instance_id, then fall back to document_id.
            $post = null;
            foreach (['_encypher_provenance_instance_id', '_encypher_provenance_document_id'] as $meta_key) {
                $query = new \WP_Query([
                    'post_type' => 'any',
                    'post_status' => 'publish',
                    'posts_per_page' => 1,
                    // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin single-post lookup by provenance meta key; bounded to posts_per_page=1.
                    'meta_query' => [
                        [
                            'key' => $meta_key,
                            'value' => $instance_id,
                            'compare' => '='
                        ]
                    ]
                ]);
                if ($query->have_posts()) {
                    $candidate = $query->posts[0];
                    // 'publish' status alone does not guarantee the post TYPE
                    // itself is publicly exposed — a custom post type can be
                    // registered with 'public' => false and still carry
                    // publish status. is_post_publicly_viewable() is the WP
                    // core check that covers both, and is required before this
                    // unauthenticated endpoint reveals anything about a post.
                    if (is_post_publicly_viewable($candidate) && empty($candidate->post_password)) {
                        $post = $candidate;
                        break;
                    }
                }
            }
            if (!$post) {
                return new WP_Error('not_found', __('No content found with this C2PA instance ID.', 'encypher-provenance'), ['status' => 404]);
            }
            $post_id = $post->ID;
        } else {
            $post_id = (int) $post_id;
            $post = get_post($post_id);
            // Same 404 for "does not exist", "not published", and "exists but
            // not publicly viewable" — an unauthenticated caller must not be
            // able to distinguish a private/non-public post from a missing one.
            if (! $post || ! is_post_publicly_viewable($post) || ! empty($post->post_password)) {
                return new WP_Error('invalid_post', __('Post not found or not published.', 'encypher-provenance'), ['status' => 404]);
            }
        }

        if (! RslExport::has_current_committed_text_provenance((int) $post_id)) {
            return new WP_Error(
                'not_found',
                __('No current provenance was found for this content.', 'encypher-provenance'),
                ['status' => 404]
            );
        }

        // Get all stored metadata
        $document_id = get_post_meta($post_id, '_encypher_provenance_document_id', true);
        $status = get_post_meta($post_id, '_encypher_provenance_status', true);
        $total_sentences = (int) get_post_meta($post_id, '_encypher_provenance_total_sentences', true);
        $last_signed = get_post_meta($post_id, '_encypher_provenance_last_signed', true);
        $last_verified = get_post_meta($post_id, '_encypher_provenance_last_verified', true);
        $verification = get_post_meta($post_id, '_encypher_provenance_verification', true);
        $sentences = $this->get_sentence_segments_payload($post_id);

        // The verification cache is cleared on every fresh sign, so a report
        // viewed before any badge click would falsely render "Not Verified"
        // for freshly signed content. Run a live verification now (same path
        // the badge uses; it repopulates the cache at its end) and re-read.
        // Fail open: when the API is unreachable, verification stays null and
        // the normalizer renders the neutral signature-present verdict.
        $signed_statuses = ['c2pa_protected', 'c2pa_verified', 'signed', 'verified'];
        if (! is_array($verification) && in_array((string) $status, $signed_statuses, true)) {
            $verify_request = new WP_REST_Request('POST', '/encypher-provenance/v1/verify');
            $verify_request->set_param('post_id', $post_id);
            $verify_response = $this->handle_verify_request($verify_request);
            if (! is_wp_error($verify_response)) {
                $verification = get_post_meta($post_id, '_encypher_provenance_verification', true);
                $last_verified = get_post_meta($post_id, '_encypher_provenance_last_verified', true);
                $status = get_post_meta($post_id, '_encypher_provenance_status', true) ?: $status;
            }
        }

        // Build provenance report
        $report = [
            'post' => [
                'id' => $post_id,
                'title' => $post->post_title,
                'author' => get_the_author_meta('display_name', $post->post_author),
                'published' => $post->post_date,
                'modified' => $post->post_modified,
                'url' => get_permalink($post),
            ],
            'c2pa' => [
                'document_id' => $document_id,
                'status' => $status,
                'last_signed' => $last_signed,
                'last_verified' => $last_verified,
                'total_sentences' => $total_sentences,
            ],
            'verification' => $verification ?: null,
            'sentences' => $sentences,
        ];

        return new WP_REST_Response($report);
    }

    public function mark_post_needs_verification($post_id, $post, $update): void
    {
        if (wp_is_post_revision($post_id) || 'auto-draft' === $post->post_status) {
            return;
        }

        if ($this->is_signing) {
            update_post_meta($post_id, '_encypher_provenance_cached_content_hash', md5((string) $post->post_content));
            return;
        }

        if ($update) {
            // Compare visible text rather than raw block markup. Adding or
            // replacing media changes post_content, but does not make the
            // already-signed article text stale.
            $previous_content = get_post_meta($post_id, '_encypher_provenance_cached_content_hash', true);
            $current_hash = md5((string) $post->post_content);
            $previous_canonical_hash = (string) get_post_meta(
                $post_id,
                '_encypher_provenance_canonical_hash',
                true
            );
            $current_canonical_hash = $this->get_parser()->canonical_text_hash(
                (string) $post->post_content
            );
            $text_changed = $previous_canonical_hash !== ''
                ? ! hash_equals($previous_canonical_hash, $current_canonical_hash)
                : ($previous_content && ! hash_equals((string) $previous_content, $current_hash));
            $is_marked = (bool) get_post_meta($post_id, '_encypher_marked', true);
            if ($is_marked && $text_changed) {
                update_post_meta($post_id, '_encypher_provenance_status', 'modified');
                delete_post_meta($post_id, '_encypher_provenance_verification');
                // Keep the previous signed-content hash so auto_sign_on_update can
                // detect the content delta and trigger a c2pa.edited re-sign.
                return;
            }
            update_post_meta($post_id, '_encypher_provenance_cached_content_hash', $current_hash);
        }
    }

    /**
     * Auto-sign posts when they transition to published status.
     *
     * Handles: New post being published (draft -> publish)
     *
     * @param string $new_status New post status
     * @param string $old_status Old post status
     * @param WP_Post $post Post object
     */
    public function auto_sign_on_publish(string $new_status, string $old_status, $post): void
    {
        if ($this->is_autosave_or_revision((int) $post->ID)
            || (class_exists(PublicationAssurance::class) && PublicationAssurance::is_releasing())
        ) {
            return;
        }
        // Only act when post becomes published (not already published)
        if ('publish' !== $new_status || 'publish' === $old_status) {
            return;
        }

        // Check if auto-signing is enabled and post type is configured
        $settings = get_option('encypher_provenance_settings', []);
        $auto_sign = isset($settings['auto_mark_on_publish']) ? (bool) $settings['auto_mark_on_publish'] : true;

        if (!$auto_sign) {
            return;
        }

        $configured_post_types = isset($settings['post_types']) && is_array($settings['post_types'])
            ? $settings['post_types']
            : ['post', 'page'];

        if (!in_array($post->post_type, $configured_post_types, true)) {
            return;
        }

        // Sign the new post
        $this->perform_signing($post->ID);
    }

    /**
     * Auto-sign posts when they are updated (already published).
     *
     * Handles: Published post being updated (publish -> publish with content changes)
     *
     * @param int $post_id Post ID
     * @param WP_Post $post Post object after update
     * @param WP_Post $post_before Post object before update
     */
    public function auto_sign_on_update(int $post_id, $post, $post_before): void
    {
        // Skip if this is the signing operation itself
        if ($this->is_signing) {
            $this->debug_log('Skipping auto_sign_on_update - is_signing flag is true');
            return;
        }

        // Skip autosaves and revisions
        if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
            return;
        }

        // Only process published posts
        if ('publish' !== $post->post_status) {
            $this->debug_log(sprintf('Post %d status is %s, not publish', $post_id, $post->post_status));
            return;
        }

        // Don't trigger on initial publish (status change from non-publish to publish)
        // The initial signing will be handled by auto_sign_on_publish
        if ($post_before && is_object($post_before) && $post_before->post_status !== 'publish') {
            $this->debug_log(sprintf('Skipping auto_sign_on_update - initial publish (status changed from %s to publish)', $post_before->post_status));
            return;
        }

        // Check if auto-signing on update is enabled and post type is configured
        $settings = get_option('encypher_provenance_settings', []);
        $auto_sign_on_update = isset($settings['auto_mark_on_update']) ? (bool) $settings['auto_mark_on_update'] : true;

        if (!$auto_sign_on_update) {
            $this->debug_log('Auto-signing on update is disabled in settings');
            return;
        }

        $configured_post_types = isset($settings['post_types']) && is_array($settings['post_types'])
            ? $settings['post_types']
            : ['post', 'page'];

        if (!in_array($post->post_type, $configured_post_types, true)) {
            return;
        }

        // Check if post is already marked
        $is_marked = get_post_meta($post_id, '_encypher_marked', true);
        $this->debug_log(sprintf('Post %d is_marked = %s', $post_id, $is_marked ? 'true' : 'false'));

        if (!$is_marked) {
            // Not signed yet - sign it now with c2pa.created
            $this->debug_log(sprintf('Post %d not marked yet, signing now with c2pa.created', $post_id));
            update_post_meta($post_id, '_encypher_action_type', 'c2pa.created');
            $this->perform_signing($post_id, false); // new-signing path, not an update
            return;
        }

        // Honor an explicit edited action only while the text projection is
        // stale. The action meta persists after a completed sign and must not
        // turn a later media-only save into another text edit.
        $action_type = get_post_meta($post_id, '_encypher_action_type', true);
        $provenance_status = get_post_meta($post_id, '_encypher_provenance_status', true);
        if ($action_type === 'c2pa.edited' && $provenance_status === 'modified') {
            $this->debug_log(sprintf('Post %d text is marked modified, performing c2pa.edited re-sign', $post_id));
            $this->perform_signing($post_id, true);
            return;
        }

        // Compare canonical visible text first. Gutenberg media block
        // attributes and generated attachment markup can change while the
        // signed prose remains byte-for-byte equivalent after parsing.
        $cached_hash = get_post_meta($post_id, '_encypher_provenance_cached_content_hash', true);
        $current_hash = md5((string) $post->post_content);
        $cached_canonical_hash = (string) get_post_meta(
            $post_id,
            '_encypher_provenance_canonical_hash',
            true
        );
        $current_canonical_hash = $this->get_parser()->canonical_text_hash(
            (string) $post->post_content
        );
        $text_changed = $cached_canonical_hash !== ''
            ? ! hash_equals($cached_canonical_hash, $current_canonical_hash)
            : (! $cached_hash || ! hash_equals((string) $cached_hash, $current_hash));

        $this->debug_log(sprintf(
            'Post %d text hash check - cached: %s, current: %s',
            $post_id,
            $cached_canonical_hash ? substr($cached_canonical_hash, 0, 8) : 'none',
            substr($current_canonical_hash, 0, 8)
        ));

        // Even when compound document signing is disabled, every unsigned
        // attachment rendered by the post still needs its own C2PA manifest.
        $has_unsigned_media = ! empty(
            $this->unsigned_post_media_ids($post_id, (string) $post->post_content, $settings)
        );

        if (! $text_changed) {
            if ($has_unsigned_media) {
                $this->debug_log(sprintf('Post %d text unchanged; signing newly referenced media only', $post_id));
                $this->perform_post_media_signing($post_id);
            } else {
                $this->debug_log(sprintf('Post %d text unchanged and all media signed, skipping re-sign', $post_id));
            }
            return;
        }

        $this->debug_log(sprintf(
            'Re-signing changed text for post %d (unsigned_media=%s)',
            $post_id,
            $has_unsigned_media ? 'true' : 'false'
        ));
        update_post_meta($post_id, '_encypher_action_type', 'c2pa.edited');
        $this->perform_signing($post_id, true);
    }

    /**
     * Handle a deferred signing event fired by WP-Cron.
     *
     * @param int  $post_id   Post ID to sign.
     * @param bool $is_update Whether this is a re-sign (c2pa.edited).
     */
    public function handle_deferred_sign(int $post_id, bool $is_update = false, int $acting_user_id = 0, string $kind = 'post'): void
    {
        $lock_token = $this->acquire_signing_worker_lock($post_id);
        if ($lock_token === null) {
            $this->debug_log(sprintf('Deferred sign skipped for post %d because another worker owns it', $post_id));
            return;
        }

        $generation = null;
        try {
            if ('attachment' === $kind) {
                // Standalone media signing: route the attachment id to the media
                // sign path rather than the post/composite signer.
                $this->sign_attachment($post_id);
                return;
            }
            if ('post_media' === $kind) {
                $this->perform_post_media_signing_now($post_id, $acting_user_id);
                return;
            }
            if ($this->is_autosave_or_revision($post_id)) {
                return;
            }
            $generation = $this->begin_text_generation($post_id);
            if (null === $generation) {
                return;
            }
            $this->deferred_generation_tokens[$post_id] = $generation;
            $this->debug_log(sprintf('Deferred sign running for post %d (is_update=%s)', $post_id, $is_update ? 'true' : 'false'));
            $this->perform_signing_now($post_id, $is_update, $acting_user_id);
        } catch (\Throwable $error) {
            $failure = new WP_Error(
                'exception',
                __('Signing stopped before the generation could commit.', 'encypher-provenance')
            );
            $failed = null;
            $unwound = $this->unwound_text_projection_terminals[$post_id] ?? '';
            if (
                is_string($generation)
                && '' !== $generation
                && '' !== $unwound
                && hash_equals($generation, $this->terminal_generation($unwound))
                && $this->owns_text_terminal('exception_abort', $post_id, $generation, $unwound)
            ) {
                $this->text_projection_terminals[$post_id] = $unwound;
            }
            if (is_string($generation) && '' !== $generation) {
                $failed = $this->fail_text_generation($post_id, $generation, $failure);
            }
            unset(
                $this->text_projection_terminals[$post_id],
                $this->unwound_text_projection_terminals[$post_id]
            );
            $this->debug_log(sprintf(
                'Deferred sign exception for post %d: %s',
                $post_id,
                $error->getMessage()
            ));
            if (null !== $failed && 'signing_superseded' !== $failed->get_error_code()) {
                ErrorLog::record_failure(
                    $post_id,
                    $failure->get_error_code(),
                    $failure->get_error_message(),
                    'auto_sign'
                );
                update_post_meta($post_id, '_encypher_last_sign_error', [
                    'code' => $failure->get_error_code(),
                    'message' => $failure->get_error_message(),
                    'time' => time(),
                ]);
                do_action('encypher_signing_failed', $post_id, $failure);
            }
        } finally {
            if (isset($this->deferred_generation_tokens[$post_id])) {
                $this->release_text_generation($post_id, $this->deferred_generation_tokens[$post_id]);
                unset($this->deferred_generation_tokens[$post_id]);
            }
            unset($this->unwound_text_projection_terminals[$post_id]);
            $this->release_signing_worker_lock($post_id, $lock_token);
        }
    }

    /**
     * Atomically claim a post across independent PHP requests using the same
     * exact JSON CAS/delete lease primitive as attachment and text commits.
     */
    private function acquire_signing_worker_lock(int $post_id): ?string
    {
        return $this->acquire_option_lease('encypher_signing_worker_' . $post_id, (string) $post_id);
    }

    private function release_signing_worker_lock(int $post_id, string $token): void
    {
        $this->release_option_lease('encypher_signing_worker_' . $post_id, $token);
    }

    /**
     * Delete only the exact mutex value this request observed.
     *
     * WordPress has no compare-and-delete option API. The conditional SQL
     * prevents a stale reclaimer or late release from deleting a replacement
     * worker's lock.
     *
     * @param mixed $expected Exact get_option() value.
     */
    private function delete_signing_worker_lock_if_matches(string $key, $expected): bool
    {
        global $wpdb;
        if (
            isset($wpdb)
            && is_object($wpdb)
            && isset($wpdb->options)
            && method_exists($wpdb, 'delete')
            && function_exists('maybe_serialize')
        ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- WordPress exposes no atomic compare-and-delete option primitive; exact value matching prevents deleting a replacement mutex.
            $deleted = $wpdb->delete(
                $wpdb->options,
                ['option_name' => $key, 'option_value' => maybe_serialize($expected)],
                ['%s', '%s']
            );
            if (1 !== $deleted) {
                return false;
            }
            wp_cache_delete($key, 'options');
            wp_cache_delete('notoptions', 'options');
            return true;
        }

        // Standalone regression harness fallback. WordPress always uses the
        // atomic database branch above.
        if (get_option($key, null) !== $expected) {
            return false;
        }
        return false !== delete_option($key);
    }

    /**
     * Public coordinator seam used by protected publication, revisions, and
     * operator commands. The existing worker owns dedupe and sync/async policy.
     */
    public function request_signing(int $post_id, bool $is_update = false): void
    {
        if (class_exists(PublicationAssurance::class)) {
            PublicationAssurance::prepare_retry($post_id);
        }
        $this->perform_signing($post_id, $is_update);
    }

    private function perform_signing(int $post_id, bool $is_update = false): void
    {
        if ($this->is_autosave_or_revision($post_id)) {
            return;
        }
        // Prevent recursion
        if ($this->is_signing) {
            return;
        }
        $post = get_post($post_id);
        $is_attachment = $post && 'attachment' === (string) ($post->post_type ?? '');

        // Snapshot the user whose action (publish/update) triggered this sign.
        // Composite (/sign/rich) signing enforces a per-attachment edit
        // capability check against the acting user (see
        // filter_authorized_media_items()); a WP-Cron deferred run has no
        // logged-in user, so this id is threaded through the scheduled event
        // and impersonated in perform_signing_now() rather than lost. In a
        // CLI/WP-CLI context this is typically 0 (no capability check needed).
        $acting_user_id = get_current_user_id();

        // Never sign inline during a web request. A rich sign can take 30-120s,
        // which blocks the editor "Update" and risks PHP's max_execution_time
        // surfacing as "Updating failed". Offload to WP-Cron so the save returns
        // immediately and the sign runs in a separate request. This works on
        // every SAPI (PHP-FPM, Apache mod_php, FastCGI, …); the previous
        // shutdown + fastcgi_finish_request approach silently dropped the text
        // sign on non-FPM SAPIs when the client disconnected mid-request.
        if (! $this->is_cli_context()) {
            // Standalone attachments share the worker hook but carry an
            // explicit routing marker. Without it, Retry sends an attachment
            // through the text signer, which rejects WordPress's `inherit`
            // status and leaves the Media Library stuck on Signing.
            $args = $is_attachment
                ? [$post_id, false, $acting_user_id, 'attachment']
                : [$post_id, $is_update, $acting_user_id];

            // Start or renew the queued lease so the editor reports "Signing"
            // until the matching worker releases it.
            SigningStatus::acquire($post_id, $is_attachment ? 'media-queue' : 'post-queue');

            if (! wp_next_scheduled('encypher_deferred_sign', $args)) {
                wp_schedule_single_event(time(), 'encypher_deferred_sign', $args);
            }

            // Nudge WP-Cron to run now (non-blocking) so signing starts within a
            // second or two instead of waiting for the next visitor request.
            $this->spawn_cron_now();

            $this->debug_log(sprintf(
                'Scheduled deferred sign for post %d via WP-Cron (is_update=%s)',
                $post_id,
                $is_update ? 'yes' : 'no'
            ));
            return;
        }

        // CLI / WP-CLI context (no reliable async cron): sign synchronously.
        if ($is_attachment) {
            $this->sign_attachment($post_id);
            return;
        }
        $this->perform_signing_now($post_id, $is_update, $acting_user_id);
    }

    /**
     * Offload signing for newly referenced page assets without re-signing text.
     */
    private function perform_post_media_signing(int $post_id): void
    {
        if ($this->is_autosave_or_revision($post_id)) {
            return;
        }

        $acting_user_id = get_current_user_id();
        if (! $this->is_cli_context()) {
            $args = [$post_id, false, $acting_user_id, 'post_media'];
            if (! wp_next_scheduled('encypher_deferred_sign', $args)) {
                wp_schedule_single_event(time(), 'encypher_deferred_sign', $args);
            }
            $this->spawn_cron_now();
            return;
        }

        $this->perform_post_media_signing_now($post_id, $acting_user_id);
    }

    /**
     * Sign unsigned assets rendered by one published post.
     */
    private function perform_post_media_signing_now(int $post_id, int $acting_user_id = 0): void
    {
        $previous_user_id = get_current_user_id();
        $impersonating = $acting_user_id > 0 && $acting_user_id !== $previous_user_id;
        if ($impersonating) {
            wp_set_current_user($acting_user_id);
        }

        try {
            $post = get_post($post_id);
            if (! $post || 'publish' !== $post->post_status) {
                return;
            }
            $settings = (array) get_option('encypher_provenance_settings', []);
            $content = (string) $post->post_content;
            $pending = $this->unsigned_post_media_ids($post_id, $content, $settings);
            $this->sign_attachments_in_waves($pending);

            $snapshot_rebased = $this->rebase_signed_snapshot_onto_media_markup($post_id, $content);
            if (
                $pending !== []
                && $this->unsigned_post_media_ids($post_id, $content, $settings) === []
                && $snapshot_rebased
                && get_post_meta($post_id, '_encypher_marked', true)
            ) {
                delete_post_meta($post_id, '_encypher_last_sign_error');
                update_post_meta($post_id, '_encypher_provenance_status', 'c2pa_protected');
                ErrorLog::record_success($post_id);
            }
        } finally {
            if ($impersonating) {
                wp_set_current_user($previous_user_id);
            }
        }
    }

    /**
     * Transfer the unchanged signed text bytes onto current media-only markup.
     *
     * The side-store is the public render source. Leaving it untouched after a
     * media-only editor save hides the new asset even after that asset is
     * signed. Re-embedding is allowed only when every extracted signed byte is
     * preserved exactly.
     */
    private function rebase_signed_snapshot_onto_media_markup(int $post_id, string $content): bool
    {
        $signed_snapshot = get_post_meta($post_id, '_encypher_signed_text', true);
        if (! is_string($signed_snapshot) || '' === $signed_snapshot) {
            return false;
        }

        $parser = $this->get_parser();
        $signed_text = $parser->extract_text($signed_snapshot);
        if ('' === $signed_text) {
            return false;
        }

        $stats = null;
        $rebased = $parser->embed_signed_text($content, $signed_text, $stats);
        if (
            ! is_array($stats)
            || empty($stats['complete'])
            || ! hash_equals(
                hash('sha256', $signed_text),
                hash('sha256', $parser->extract_text($rebased))
            )
        ) {
            $this->debug_log(sprintf('Post %d media markup could not preserve its signed text snapshot', $post_id));
            return false;
        }

        return false !== update_post_meta($post_id, '_encypher_signed_text', $rebased);
    }

    /**
     * Whether we are running under WP-CLI or a bare PHP CLI process, where
     * WP-Cron offload is unreliable and signing should run synchronously.
     */
    private function is_cli_context(): bool
    {
        if (defined('WP_CLI') && WP_CLI) {
            return true;
        }
        return 'cli' === php_sapi_name();
    }

    /**
     * Best-effort, non-blocking nudge to run due WP-Cron events immediately.
     *
     * spawn_cron() fires a fire-and-forget loopback request to wp-cron.php. If
     * it fails (loopback blocked) the event still runs on the next site
     * request via core's wp_cron(). When WP-Cron is disabled in favour of a
     * system crontab we leave it to that scheduler.
     */
    private function spawn_cron_now(): void
    {
        if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
            return;
        }
        if (function_exists('spawn_cron')) {
            spawn_cron();
        }
    }

    /**
     * Drop media items the acting user is not authorized to mutate.
     *
     * Composite (/sign/rich) signing overwrites the referenced attachment's
     * file on disk and writes C2PA metadata to it. Without this check, a
     * user who can edit ONE post but references an attachment ID they do not
     * own/cannot edit (via a hand-crafted or pasted block attribute, or a
     * classic-editor HTML paste) could get that other user's media file
     * silently overwritten and re-attributed. Checked against the ambient
     * current user: for an interactive REST call this is the real logged-in
     * editor; for a WP-Cron deferred sign, perform_signing_now() has already
     * impersonated the user whose save triggered it via wp_set_current_user().
     *
     * Unauthorized items are dropped, not fatal: the caller falls back to
     * text-only signing when the resulting array is empty, so an
     * unauthorized reference degrades the request instead of blocking it.
     *
     * @param array<int,array<string,mixed>> $media_items
     * @return array<int,array<string,mixed>>
     */
    private function filter_authorized_media_items(array $media_items, int $post_id): array
    {
        $authorized = [];
        $dropped = [];
        foreach ($media_items as $item) {
            $attachment_id = (int) ($item['attachment_id'] ?? 0);
            if ($attachment_id > 0 && current_user_can('edit_post', $attachment_id)) {
                $authorized[] = $item;
            } elseif ($attachment_id > 0) {
                $dropped[] = $attachment_id;
            }
        }
        if (! empty($dropped)) {
            Logger::log(sprintf(
                'Encypher: post %d referenced attachment(s) [%s] the acting user cannot edit; excluded from composite signing',
                $post_id,
                implode(',', $dropped)
            ));
        }
        return $authorized;
    }

    /**
     * Resolve authorized, signable page assets that do not currently carry
     * byte-exact provenance.
     *
     * @return array<int,int>
     */
    private function unsigned_post_media_ids(int $post_id, string $content, array $settings): array
    {
        if (empty($settings['auto_sign_images'])) {
            return [];
        }

        $extractor = new MediaBlockExtractor();
        $media_items = $extractor->extract_media_from_content($content, $post_id, true, false);
        $media_items = $this->filter_authorized_media_items($media_items, $post_id);
        $ids = [];
        foreach ($media_items as $item) {
            $attachment_id = (int) ($item['attachment_id'] ?? 0);
            if (
                $attachment_id > 0
                && ! in_array($attachment_id, $ids, true)
                && ! VipMediaDelivery::carries_provenance($attachment_id)
            ) {
                $ids[] = $attachment_id;
            }
        }
        return $ids;
    }

    /**
     * Sign every unsigned attachment rendered by a post, including assets
     * omitted from compound signing because that mode is disabled or bounded.
     */
    private function sign_unsigned_post_media(int $post_id, string $content, array $settings): void
    {
        $this->sign_attachments_in_waves(
            $this->unsigned_post_media_ids($post_id, $content, $settings)
        );
    }

    public function perform_signing_now(int $post_id, bool $is_update = false, int $acting_user_id = 0): void
    {
        if ($this->is_autosave_or_revision($post_id)) {
            return;
        }
        // Prevent recursion
        if ($this->is_signing) {
            return;
        }

        // Impersonate the user whose action triggered this sign so the
        // per-attachment edit-capability check in filter_authorized_media_items()
        // evaluates against a real user instead of the anonymous WP-Cron
        // request context. Restored unconditionally in the finally block below.
        $previous_user_id = get_current_user_id();
        $impersonating = $acting_user_id > 0 && $acting_user_id !== $previous_user_id;
        if ($impersonating) {
            wp_set_current_user($acting_user_id);
        }

        $lease_token = SigningStatus::claim($post_id, 'post-worker');
        // A fresh attempt starts: clear any previous failure record.
        delete_post_meta($post_id, '_encypher_last_sign_error');

        try {
            // Deferred generation authority stays in this instance and is
            // never copied into REST request params.
            $request = new WP_REST_Request('POST', '/encypher-provenance/v1/sign');
            $request->set_param('post_id', $post_id);
            $request->set_param('metadata', []);

            // A worker that lost ownership must not sign under a replacement
            // run's visible state.
            if (! SigningStatus::heartbeat($post_id, $lease_token)) {
                return;
            }
            $response = $this->handle_sign_request($request);
            $lease_active = SigningStatus::heartbeat($post_id, $lease_token);

            if (is_wp_error($response)) {
                if ('signing_superseded' === $response->get_error_code()) {
                    // A newer generation already owns or committed the post.
                    // The stale worker must not publish an error over that
                    // winner's terminal state.
                    $this->debug_log(sprintf('Auto-sign for post %d was superseded by a newer generation', $post_id));
                } else {
                    // Text-chain failures must not strand new page assets. Media
                    // carries its own manifest and can still be signed independently.
                    $post_for_media = $lease_active ? get_post($post_id) : null;
                    if ($post_for_media && 'publish' === $post_for_media->post_status) {
                        $this->sign_unsigned_post_media(
                            $post_id,
                            (string) $post_for_media->post_content,
                            (array) get_option('encypher_provenance_settings', [])
                        );
                    }
                    $this->debug_log(sprintf(
                        'Auto-sign failed for post %d: %s',
                        $post_id,
                        $response->get_error_message()
                    ));
                    $context = 'auto_sign';
                    ErrorLog::record_failure(
                        $post_id,
                        $response->get_error_code(),
                        $response->get_error_message(),
                        $context
                    );
                    update_post_meta($post_id, '_encypher_last_sign_error', [
                        'code'    => (string) $response->get_error_code(),
                        'message' => $response->get_error_message(),
                        'time'    => time(),
                    ]);
                    $consecutive = ErrorLog::get_consecutive_failures($post_id);
                    ErrorLog::maybe_fire_webhook($post_id, [
                        'post_title'          => get_the_title($post_id) ?: sprintf('Post #%d', $post_id),
                        'error_code'          => $response->get_error_code(),
                        'error_message'       => $response->get_error_message(),
                        'consecutive_failures' => $consecutive,
                        'timestamp'           => gmdate('c'),
                        'context'             => $context,
                    ]);
                    do_action('encypher_signing_failed', $post_id, $response);
                }
            } else {
                $this->debug_log(sprintf(
                    'Auto-signed post %d (%s)',
                    $post_id,
                    $is_update ? 'updated' : 'new'
                ));
                $response_data = ($response instanceof WP_REST_Response) ? (array) $response->get_data() : [];
                if (isset($response_data['status']) && in_array($response_data['status'], ['embedding_failed', 'media_signing_failed'], true)) {
                    // The sign handler recorded _encypher_last_sign_error; do
                    // not clear it by recording success for an incomplete run.
                    $this->debug_log(sprintf('Auto-sign for post %d finished with %s', $post_id, $response_data['status']));
                    do_action(
                        'encypher_signing_failed',
                        $post_id,
                        new WP_Error((string) $response_data['status'], __('The signing run did not protect every required component.', 'encypher-provenance'))
                    );
                } else {
                    ErrorLog::record_success($post_id);
                    do_action('encypher_signing_completed', $post_id, $response_data);
                }
            }
        } catch (\Throwable $e) {
            if (isset($this->deferred_generation_tokens[$post_id])) {
                throw $e;
            }
            $this->debug_log(sprintf(
                'Auto-sign exception for post %d: %s',
                $post_id,
                $e->getMessage()
            ));
            ErrorLog::record_failure(
                $post_id,
                'exception',
                $e->getMessage(),
                'auto_sign'
            );
            update_post_meta($post_id, '_encypher_last_sign_error', [
                'code'    => 'exception',
                'message' => $e->getMessage(),
                'time'    => time(),
            ]);
            ErrorLog::maybe_fire_webhook($post_id, [
                'post_title'          => get_the_title($post_id) ?: sprintf('Post #%d', $post_id),
                'error_code'          => 'exception',
                'error_message'       => $e->getMessage(),
                'consecutive_failures' => ErrorLog::get_consecutive_failures($post_id),
                'timestamp'           => gmdate('c'),
                'context'             => 'auto_sign',
            ]);
            do_action('encypher_signing_failed', $post_id, new WP_Error('exception', $e->getMessage()));
        } finally {
            SigningStatus::release($post_id, $lease_token);
            if ($impersonating) {
                wp_set_current_user($previous_user_id);
            }
        }
    }

    /**
     * Show a dismissible admin notice on the post edit screen when the last
     * auto-sign attempt failed.
     */
    public function render_sign_error_notice(): void
    {
        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        if (! $screen || ! in_array($screen->base, ['post', 'post-new'], true)) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only (int) cast of the WP core editor-screen post id in an admin enqueue hook; no state change.
        $post_id = isset($_GET['post']) ? (int) $_GET['post'] : 0;
        if ($post_id <= 0) {
            return;
        }

        $dismissed = get_post_meta($post_id, '_encypher_sign_error_dismissed', true);
        if ($dismissed) {
            return;
        }

        $error = get_post_meta($post_id, '_encypher_last_sign_error', true);
        if (! is_array($error) || empty($error['message'])) {
            return;
        }

        $consecutive = (int) ($error['consecutive'] ?? 1);
        $timestamp   = isset($error['timestamp']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), strtotime($error['timestamp'])) : '';
        $nonce       = wp_create_nonce('encypher_dismiss_sign_error_' . $post_id);
        ?>
        <div class="notice notice-error encypher-sign-error-notice" style="position:relative;padding-right:48px;">
            <p>
                <strong><?php esc_html_e('Encypher: Auto-sign failed', 'encypher-provenance'); ?></strong>
                <?php if ($timestamp): ?>
                    <span style="color:#646970;font-size:12px;margin-left:8px;"><?php echo esc_html($timestamp); ?></span>
                <?php endif; ?>
            </p>
            <p>
                <?php echo esc_html($error['message']); ?>
                <?php if ($consecutive > 1): ?>
                    <span style="color:#d63638;margin-left:8px;">
                        <?php echo esc_html(sprintf(
                            /* translators: %d: number of consecutive failures */
                            _n('%d consecutive failure', '%d consecutive failures', $consecutive, 'encypher-provenance'),
                            $consecutive
                        )); ?>
                    </span>
                <?php endif; ?>
            </p>
            <p>
                <a href="<?php echo esc_url(admin_url('admin.php?page=encypher#error-log')); ?>">
                    <?php esc_html_e('View error log', 'encypher-provenance'); ?>
                </a>
                &nbsp;&middot;&nbsp;
                <a href="<?php echo esc_url(admin_url('admin.php?page=encypher')); ?>">
                    <?php esc_html_e('Check API settings', 'encypher-provenance'); ?>
                </a>
            </p>
            <button type="button"
                class="notice-dismiss"
                onclick="jQuery.post(ajaxurl, {action:'encypher_dismiss_sign_error', post_id:<?php echo (int) $post_id; ?>, _wpnonce:'<?php echo esc_js($nonce); ?>'});"
                style="position:absolute;top:8px;right:8px;">
                <span class="screen-reader-text"><?php esc_html_e('Dismiss this notice', 'encypher-provenance'); ?></span>
            </button>
        </div>
        <?php
    }

    /**
     * AJAX handler: dismiss the sign-error notice for a post.
     */
    public function ajax_dismiss_sign_error(): void
    {
        $post_id = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
        if ($post_id <= 0 || ! current_user_can('edit_post', $post_id)) {
            wp_send_json_error('Unauthorized', 403);
        }
        check_ajax_referer('encypher_dismiss_sign_error_' . $post_id);
        update_post_meta($post_id, '_encypher_sign_error_dismissed', true);
        wp_send_json_success();
    }

    private function derive_status_from_verification(array $response): string
    {
        if (! empty($response['is_valid'])) {
            return 'verified';
        }

        if (isset($response['tampered']) && $response['tampered']) {
            return 'tampered';
        }

        return 'signed';
    }

    private function resolve_signed_text_with_embedding_plan(array $result, string $visible_text): ?string
    {
        // The Enterprise API response signed_text is the AUTHORITATIVE artifact:
        // it is the exact byte sequence the C2PA manifest hard-binds and that
        // the engine verified at sign time. Prefer it VERBATIM so the stored
        // snapshot is verifiable by construction. Local reconstruction from the
        // embedding_plan is kept ONLY as a fallback for responses that omit
        // signed_text (older / degraded backends). When BOTH are present we
        // byte-compare them and log a warning on any divergence -- a mismatch
        // signals a canonicalization drift between the plugin's local
        // application and the current engine, but the authoritative server
        // signed_text is stored regardless so verification never regresses.
        $server_signed = $result['signed_text'] ?? ($result['embedded_content'] ?? null);
        $has_server_signed = is_string($server_signed) && '' !== $server_signed;

        $local_signed = null;
        if (isset($result['embedding_plan']) && is_array($result['embedding_plan'])) {
            $resolved = $this->apply_embedding_plan($visible_text, $result['embedding_plan']);
            if (is_string($resolved) && '' !== $resolved) {
                $local_signed = $resolved;
            }
        }

        if ($has_server_signed) {
            if (null !== $local_signed && $local_signed !== $server_signed) {
                Logger::log(sprintf(
                    'Encypher WARNING: embedding_plan reconstruction diverged from the Enterprise API signed_text (local=%d bytes, server=%d bytes); storing the authoritative server signed_text.',
                    strlen($local_signed),
                    strlen((string) $server_signed)
                ));
            }
            return $server_signed;
        }

        // No server signed_text present: fall back to the local reconstruction.
        if (null !== $local_signed) {
            return $local_signed;
        }

        return null;
    }

    private function apply_embedding_plan(string $visible_text, array $embedding_plan): ?string
    {
        if (
            !isset($embedding_plan['index_unit'])
            || !is_string($embedding_plan['index_unit'])
            || 'codepoint' !== $embedding_plan['index_unit']
        ) {
            return null;
        }

        if (!isset($embedding_plan['operations']) || !is_array($embedding_plan['operations']) || empty($embedding_plan['operations'])) {
            return null;
        }

        $chars = $this->mb_str_split_safe($visible_text);
        $char_count = count($chars);
        $markers_by_position = [];

        foreach ($embedding_plan['operations'] as $operation) {
            if (!is_array($operation)) {
                return null;
            }

            if (!isset($operation['insert_after_index'], $operation['marker'])) {
                return null;
            }

            if (!is_int($operation['insert_after_index']) || !is_string($operation['marker']) || '' === $operation['marker']) {
                return null;
            }

            $insert_after_index = $operation['insert_after_index'];
            if ($insert_after_index < -1 || $insert_after_index >= $char_count) {
                return null;
            }

            if (!isset($markers_by_position[$insert_after_index])) {
                $markers_by_position[$insert_after_index] = [];
            }
            $markers_by_position[$insert_after_index][] = $operation['marker'];
        }

        $output = '';

        if (isset($markers_by_position[-1])) {
            foreach ($markers_by_position[-1] as $marker) {
                $output .= $marker;
            }
        }

        for ($idx = 0; $idx < $char_count; $idx++) {
            $output .= $chars[$idx];
            if (isset($markers_by_position[$idx])) {
                foreach ($markers_by_position[$idx] as $marker) {
                    $output .= $marker;
                }
            }
        }

        return $output;
    }

    // =========================================================================
    // =========================================================================
    // Block-level rights collection
    // =========================================================================

    /**
     * Text block types whose content maps to sentence segments.
     */
    private const TEXT_BLOCK_TYPES = [
        'core/paragraph',
        'core/heading',
        'core/list',
        'core/quote',
    ];

    /**
     * Collect block-level rights from Gutenberg attributes and document-level
     * meta, then inject into $context (for text signing) and $media_items (for
     * per-media signing).
     *
     * @param int    $post_id     Post ID.
     * @param string $content     Post content (HTML with block comments).
     * @param array  &$media_items Media descriptors (modified in place to add rights).
     * @param array  &$context     Sign context (modified in place to add segment_rights, license).
     * @param array  $settings     Plugin settings (document-level rights folded in).
     * @return array The merged settings with document-level template rights folded in.
     */
    private function collect_block_rights(
        int $post_id,
        string $content,
        array &$media_items,
        array &$context,
        array $settings
    ): array {
        $segment_rights_entitled = Capabilities::allows(Capabilities::SEGMENT_RIGHTS);

        // --- 1. Document-level rights from post meta ---
        $doc_rights_json = get_post_meta($post_id, '_encypher_rights', true);
        $doc_rights = $doc_rights_json
            ? (is_string($doc_rights_json) ? json_decode($doc_rights_json, true) : $doc_rights_json)
            : null;
        if (!is_array($doc_rights)) {
            $doc_rights = null;
        }

        if ($doc_rights && ! empty($doc_rights['template'])) {
            $tpl_key = (string) $doc_rights['template'];
            if ('custom' === $tpl_key) {
                // A deliberate custom selection is distinct from both an
                // opaque dashboard schema and the site fallback.
                $custom = is_array($doc_rights['custom'] ?? null) ? $doc_rights['custom'] : [];
                $doc_api_rights = $this->build_rights_metadata_from_data($custom);
                foreach (['ai_training', 'ai_inference', 'data_mining'] as $ai_field) {
                    if (array_key_exists($ai_field, $doc_rights)) {
                        $doc_api_rights[$ai_field] = (bool) $doc_rights[$ai_field];
                    }
                }
                $context['rights_custom_override'] = true;
                if (! empty($doc_api_rights)) {
                    $context['rights'] = $doc_api_rights;
                }
                if (! empty($custom['license_type'])) {
                    $context['license_type'] = (string) $custom['license_type'];
                }
                if (! empty($custom['license_url'])) {
                    $context['license_url'] = (string) $custom['license_url'];
                }
            } else {
                // The opaque id is authoritative. No site rights, license, or
                // stale editor fields accompany it.
                $context['rights_template_id'] = $tpl_key;
            }
        }

        // --- 2. Parse blocks for per-block encypherRights attributes ---
        if (!function_exists('parse_blocks')) {
            return $settings;
        }

        $blocks = $this->flatten_blocks_with_inherited_rights(parse_blocks($content));
        $segment_rights = [];
        $sentence_index = 0;

        foreach ($blocks as $block) {
            $block_name = $block['blockName'] ?? '';
            if (!$block_name) {
                continue;
            }

            $block_rights = $block['attrs']['encypherRights'] ?? null;

            // 2a. Text blocks: map to segment indices for segment_rights.
            if (in_array($block_name, self::TEXT_BLOCK_TYPES, true)) {
                $block_text = $this->extract_text_from_block($block);
                $sentence_count = $this->count_sentences($block_text);

                if ($block_rights && $sentence_count > 0 && $segment_rights_entitled) {
                    $entry = $this->build_segment_rights_entry($block_rights);
                    if ($entry !== null) {
                        $entry['segment_indices'] = range($sentence_index, $sentence_index + $sentence_count - 1);
                        // WS4: key per-block rights by the stable block UUID
                        // (WS0.2) so RSL export and per-block VS256-RS markers
                        // can reference the same block identity.
                        $entry['block_id'] = isset($block['attrs']['encypherBlockId']) ? (string) $block['attrs']['encypherBlockId'] : null;
                        $segment_rights[] = $entry;
                    }
                }
                $sentence_index += $sentence_count;
            }

            // 2b. Media blocks: attach rights to every referenced attachment.
            if ($segment_rights_entitled && $block_rights && $this->is_media_block($block_name)) {
                $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
                $attachment_ids = [];
                if ($block_name === 'core/gallery') {
                    $attachment_ids = is_array($attrs['ids'] ?? null)
                        ? $attrs['ids']
                        : preg_split('/\s*,\s*/', (string) ($attrs['ids'] ?? ''), -1, PREG_SPLIT_NO_EMPTY);
                } elseif ($block_name === 'core/media-text') {
                    $attachment_ids = [(int) ($attrs['mediaId'] ?? 0)];
                } else {
                    $attachment_ids = [(int) ($attrs['id'] ?? 0)];
                }
                $attachment_ids = array_map('intval', $attachment_ids);
                $entry = $this->build_segment_rights_entry($block_rights);
                if ($entry !== null) {
                    foreach ($media_items as &$item) {
                        if (!in_array((int) ($item['attachment_id'] ?? 0), $attachment_ids, true)) {
                            continue;
                        }
                        if (isset($entry['rights_template_id'])) {
                            $item['rights_template_id'] = $entry['rights_template_id'];
                        }
                        if (isset($entry['rights'])) {
                            $item['rights'] = $entry['rights'];
                        }
                    }
                    unset($item);
                }
            }
        }

        // Attachment-level rights: for any media item still without a per-block
        // override, fall back to the per-asset _encypher_rights selection before
        // the document/site default. Precedence: per-block > attachment >
        // document > site/org default.
        if ($segment_rights_entitled) {
            foreach ($media_items as &$attachment_item) {
                if (isset($attachment_item['rights_template_id']) || isset($attachment_item['rights'])) {
                    continue;
                }
                $attachment_rights = $this->read_attachment_rights((int) ($attachment_item['attachment_id'] ?? 0));
                if ($attachment_rights === null) {
                    continue;
                }
                $attachment_entry = $this->build_segment_rights_entry($attachment_rights);
                if ($attachment_entry === null) {
                    continue;
                }
                if (isset($attachment_entry['rights_template_id'])) {
                    $attachment_item['rights_template_id'] = $attachment_entry['rights_template_id'];
                }
                if (isset($attachment_entry['rights'])) {
                    $attachment_item['rights'] = $attachment_entry['rights'];
                }
            }
            unset($attachment_item);
        }


        // Add block-level rights only when the server grants segment_rights.
        if (!empty($segment_rights) && $segment_rights_entitled) {
            if (empty($context['segment_rights'])) {
                $context['segment_rights'] = $segment_rights;
            } else {
                $context['segment_rights'] = array_merge($context['segment_rights'], $segment_rights);
            }
        }

        return $settings;
    }

    /**
     * Flatten supported core layouts while carrying a parent media declaration
     * to their child media blocks. Custom blocks remain opaque by design.
     */
    private function flatten_blocks_with_inherited_rights(array $blocks, ?array $inherited_rights = null): array
    {
        $flat = [];
        foreach ($blocks as $block) {
            $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
            if ($inherited_rights !== null && empty($attrs['encypherRights'])) {
                $attrs['encypherRights'] = $inherited_rights;
                $block['attrs'] = $attrs;
            }
            $flat[] = $block;
            $inherits = in_array($block['blockName'] ?? '', ['core/group', 'core/columns', 'core/column', 'core/gallery', 'core/media-text', 'core/cover'], true)
                ? ($attrs['encypherRights'] ?? $inherited_rights)
                : null;
            if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
                $flat = array_merge($flat, $this->flatten_blocks_with_inherited_rights($block['innerBlocks'], is_array($inherits) ? $inherits : null));
            }
        }
        return $flat;
    }

    /**
     * Build a per-block/per-media rights entry from encypherRights attributes.
     * Opaque dashboard ids travel alone. Only an explicit custom selection can
     * carry expanded fields.
     */
    private function build_segment_rights_entry(array $block_rights): ?array
    {
        $tpl = isset($block_rights['template']) ? (string) $block_rights['template'] : '';
        if ('' !== $tpl && 'custom' !== $tpl) {
            return ['rights_template_id' => $tpl];
        }
        $custom = 'custom' === $tpl && is_array($block_rights['custom'] ?? null)
            ? $block_rights['custom']
            : $block_rights;
        $explicit = $this->build_rights_metadata_from_data($custom);
        return ! empty($explicit) ? ['rights' => $explicit] : null;
    }

    /**
     * Build a RightsMetadata-compatible array from template/custom data.
     */
    private function build_rights_metadata_from_data(array $data): array
    {
        $rights = [];
        if (!empty($data['copyright_holder'])) {
            $rights['copyright_holder'] = $data['copyright_holder'];
        }
        if (!empty($data['url'])) {
            $rights['license_url'] = $data['url'];
        }
        if (!empty($data['license_url'])) {
            $rights['license_url'] = $data['license_url'];
        }
        if (!empty($data['usage_terms'])) {
            $rights['usage_terms'] = $data['usage_terms'];
        }
        if (isset($data['syndication_allowed'])) {
            $rights['syndication_allowed'] = (bool) $data['syndication_allowed'];
        }
        if (!empty($data['embargo_until'])) {
            $rights['embargo_until'] = $data['embargo_until'];
        }
        if (!empty($data['contact_email'])) {
            $rights['contact_email'] = $data['contact_email'];
        }
        if (isset($data['ai_training'])) {
            $rights['ai_training'] = (bool) $data['ai_training'];
        }
        if (isset($data['ai_inference'])) {
            $rights['ai_inference'] = (bool) $data['ai_inference'];
        }
        if (isset($data['data_mining'])) {
            $rights['data_mining'] = (bool) $data['data_mining'];
        }
        return $rights;
    }

    /**
     * Read a per-asset rights selection from attachment meta. Same shape as the
     * per-block encypherRights attribute ({template} or explicit fields).
     *
     * @return array<string,mixed>|null Decoded rights, or null when unset/empty.
     */
    private function read_attachment_rights(int $attachment_id): ?array
    {
        if ($attachment_id <= 0) {
            return null;
        }
        $raw = get_post_meta($attachment_id, '_encypher_rights', true);
        $rights = is_string($raw) ? json_decode($raw, true) : $raw;
        if (! is_array($rights) || empty($rights)) {
            return null;
        }
        return $rights;
    }

    /**
     * Project one attachment into the /sign/media rights contract used by both
     * standalone and featured-image signing. Verified source rights win first;
     * otherwise attachment selection wins over parent document selection, with
     * unchanged settings providing the site/org fallback.
     *
     * @param array<string,mixed> $settings Plugin settings (returned unchanged).
     * @param array<string,mixed> $context  Media sign context (rights folded in).
     * @return array<string,mixed> The settings, unchanged.
     */
    private function apply_media_rights_projection(
        int $attachment_id,
        int $parent_post_id,
        array $settings,
        array &$context
    ): array {
        $has_verified_source = (bool) get_post_meta($attachment_id, '_encypher_source_has_c2pa', true)
            && (bool) get_post_meta($attachment_id, '_encypher_source_c2pa_valid', true);
        if ($has_verified_source) {
            $context['preserve_source_rights'] = true;
            return $settings;
        }

        $rights = $this->read_attachment_rights($attachment_id);
        if ($rights === null && $parent_post_id > 0) {
            $rights = $this->read_attachment_rights($parent_post_id);
        }
        if ($rights === null) {
            return $settings;
        }

        $entry = $this->build_segment_rights_entry($rights);
        $template = isset($rights['template']) ? (string) $rights['template'] : '';
        if ('custom' === $template) {
            $context['rights_custom_override'] = true;
        }
        if ($entry !== null) {
            if (isset($entry['rights_template_id'])) {
                $context['rights_template_id'] = $entry['rights_template_id'];
            }
            if (isset($entry['rights'])) {
                $context['rights'] = $entry['rights'];
            }
        }
        if ('custom' === $template || '' === $template) {
            if (! empty($rights['license_type'])) {
                $context['license_type'] = (string) $rights['license_type'];
            }
            if (! empty($rights['license_url'])) {
                $context['license_url'] = (string) $rights['license_url'];
            }
        }
        return $settings;
    }

    /**
     * Extract plain text from a Gutenberg block's inner HTML.
     */
    private function extract_text_from_block(array $block): string
    {
        $html = $block['innerHTML'] ?? '';
        if (empty($html)) {
            // Recurse into inner blocks (e.g. list items).
            foreach ($block['innerBlocks'] ?? [] as $inner) {
                $html .= $inner['innerHTML'] ?? '';
            }
        }
        return wp_strip_all_tags($html);
    }

    /**
     * Count the number of sentences in a text string.
     *
     * Uses the same heuristic as the API's sentence tokenizer: split on
     * sentence-ending punctuation followed by whitespace or end of string.
     * Returns at least 1 for non-empty text.
     */
    private function count_sentences(string $text): int
    {
        $text = trim($text);
        if ($text === '') {
            return 0;
        }
        // Split on .!? followed by whitespace or end of string.
        $parts = preg_split('/(?<=[.!?])\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        return max(1, count($parts));
    }

    /**
     * Check if a block type is a media block.
     */
    private function is_media_block(string $block_name): bool
    {
        return in_array($block_name, [
            'core/image',
            'core/audio',
            'core/video',
            'core/gallery',
            'core/cover',
            'core/media-text',
        ], true);
    }

    // HTML parsing — delegated to HtmlParser
    // =========================================================================

    private function extract_text_from_html(string $html): string
    {
        // WS0.1: sign-time visible text MUST come from the single canonical
        // definition (marker-stripped, NFC-normalized) so it matches the
        // content-hash guard and the verify-time derivation. The backend
        // NFC-normalizes on both sign and verify, so this is hash-stable.
        return $this->get_parser()->canonical_visible_text($html);
    }

    private function embed_signed_text_in_html(string $html, string $signed, ?array &$stats = null): string
    {
        return $this->get_parser()->embed_signed_text($html, $signed, $stats);
    }

    private function extract_html_text_fragments(string $html): array
    {
        return $this->get_parser()->extract_fragments($html);
    }

    private function extract_text_for_verify(string $html): string
    {
        return $this->get_parser()->extract_text_for_verify($html);
    }

    private function sanitize_wp_block_comments(string $content): string
    {
        return $this->get_parser()->sanitize_wp_block_comments($content);
    }

    private function mb_str_split_safe(string $str): array
    {
        return $this->get_parser()->mb_str_split_safe($str);
    }

    private function is_vs_char(string $ch): bool
    {
        return $this->get_parser()->is_vs_char($ch);
    }

    private function is_vs_or_whitespace(string $ch): bool
    {
        return $this->get_parser()->is_vs_or_whitespace($ch);
    }

    private function detect_c2pa_embeddings(string $text): array
    {
        return $this->get_parser()->detect_c2pa_embeddings($text);
    }

    private function has_leaf_pointer_presentation(array $layer, string $text): bool
    {
        if (empty($layer)) {
            return false;
        }
        return $this->get_parser()->has_leaf_pointer_presentation($layer, $text);
    }

    private function strip_c2pa_embeddings(string $text): string
    {
        return $this->get_parser()->strip_c2pa_embeddings($text);
    }

    private function persist_sentence_segments(int $post_id, array $embeddings): string
    {
        if (empty($embeddings)) {
            return $this->delete_generation_meta(
                $post_id,
                '_encypher_provenance_sentence_segments'
            );
        }

        $normalized = [];
        foreach ($embeddings as $segment) {
            if (!is_array($segment) || !isset($segment['leaf_index'])) {
                continue;
            }

            $preview = '';
            if (isset($segment['text'])) {
                $preview = wp_strip_all_tags((string) $segment['text']);
            }
            $snippet = function_exists('mb_substr') ? mb_substr($preview, 0, 160) : substr($preview, 0, 160);
            $char_count = function_exists('mb_strlen') ? mb_strlen($preview) : strlen($preview);

            $normalized[] = [
                'leaf_index' => (int) $segment['leaf_index'],
                'preview' => $snippet,
                'characters' => $char_count,
                'ref_id' => isset($segment['ref_id']) ? sanitize_text_field((string) $segment['ref_id']) : '',
                'verification_url' => isset($segment['verification_url']) ? esc_url_raw((string) $segment['verification_url']) : '',
            ];
        }

        return !empty($normalized)
            ? $this->project_generation_meta(
                $post_id,
                '_encypher_provenance_sentence_segments',
                $normalized
            )
            : $this->delete_generation_meta(
                $post_id,
                '_encypher_provenance_sentence_segments'
            );
    }

    private function get_sentence_segments_payload(int $post_id): array
    {
        $segments = get_post_meta($post_id, '_encypher_provenance_sentence_segments', true);
        if (!is_array($segments)) {
            return [
                'items' => [],
                'total' => 0,
            ];
        }

        $items = [];
        foreach ($segments as $segment) {
            if (!is_array($segment) || !isset($segment['leaf_index'])) {
                continue;
            }
            $preview = isset($segment['preview']) ? $segment['preview'] : '';
            $char_count = isset($segment['characters']) ? (int) $segment['characters'] : (function_exists('mb_strlen') ? mb_strlen($preview) : strlen($preview));
            $items[] = [
                'leaf_index' => (int) $segment['leaf_index'],
                'preview' => $preview,
                'characters' => $char_count,
                'verification_url' => isset($segment['verification_url']) ? $segment['verification_url'] : '',
                'ref_id' => isset($segment['ref_id']) ? $segment['ref_id'] : '',
            ];
        }

        return [
            'items' => array_slice($items, 0, 50),
            'total' => count($items),
        ];
    }

    /**
     * Handle POST /account/refresh: fetch the current server account and
     * WordPress capability projection.
     */
    public function handle_account_refresh(WP_REST_Request $request)
    {
        $settings = get_option('encypher_provenance_settings', []);
        $api_base_url = isset($settings['api_base_url']) ? self::normalize_api_base_url((string) ($settings['api_base_url'] ?? '')) : '';
        $api_key = isset($settings['api_key']) ? trim((string) $settings['api_key']) : '';

        if ('' === $api_base_url || '' === $api_key) {
            return new WP_Error(
                'missing_configuration',
                __('API key and base URL must be configured.', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        // Bust the transient cache
        $cache_key = 'encypher_account_' . md5(strtolower($api_base_url) . '|' . substr(hash('sha256', $api_key), 0, 16));
        delete_site_transient($cache_key);

        // Fetch fresh account data
        $account = $this->fetch_remote_account($api_base_url, $api_key);
        if (is_wp_error($account)) {
            return $account;
        }

        // Persist updated fields to settings
        $settings['tier'] = $account['tier'] ?? 'free';
        $settings['organization_id'] = $account['organization_id'] ?? '';
        $settings['organization_name'] = $account['organization_name'] ?? '';
        $settings['add_ons'] = $account['add_ons'] ?? [];
        $settings['features'] = $account['features'] ?? [];
        $settings['connection_last_checked_at'] = gmdate('c');
        Capabilities::store_server_projection(
            is_array($account['wordpress_entitlements'] ?? null) ? $account['wordpress_entitlements'] : [],
            (string) ($account['organization_id'] ?? '')
        );
        update_option('encypher_provenance_settings', $settings);

        $snapshot = Capabilities::snapshot();
        return new WP_REST_Response([
            'success' => true,
            'tier' => $settings['tier'],
            'add_ons' => $settings['add_ons'],
            'capabilities' => is_array($snapshot['capabilities'] ?? null)
                ? $snapshot['capabilities']
                : [],
        ]);
    }
    public function refresh_account_entitlements(): void
    {
        $settings = get_option('encypher_provenance_settings', []);
        $api_base_url = is_array($settings)
            ? self::normalize_api_base_url((string) ($settings['api_base_url'] ?? ''))
            : '';
        $api_key = is_array($settings) ? trim((string) ($settings['api_key'] ?? '')) : '';
        if ('' === $api_base_url || '' === $api_key) {
            Capabilities::clear();
            return;
        }

        $cache_key = 'encypher_account_' . md5(strtolower($api_base_url) . '|' . substr(hash('sha256', $api_key), 0, 16));
        delete_site_transient($cache_key);
        $account = $this->fetch_remote_account($api_base_url, $api_key);
        if (is_wp_error($account)) {
            $this->schedule_entitlement_refresh(15 * MINUTE_IN_SECONDS);
            return;
        }

        $settings['tier'] = $account['tier'] ?? 'free';
        $settings['organization_id'] = $account['organization_id'] ?? '';
        $settings['organization_name'] = $account['organization_name'] ?? '';
        $settings['features'] = $account['features'] ?? [];
        $settings['add_ons'] = $account['add_ons'] ?? [];
        $settings['connection_last_checked_at'] = gmdate('c');
        update_option('encypher_provenance_settings', $settings);
        $stored = Capabilities::store_server_projection(
            is_array($account['wordpress_entitlements'] ?? null) ? $account['wordpress_entitlements'] : [],
            (string) ($account['organization_id'] ?? '')
        );
        $this->schedule_entitlement_refresh($stored ? Capabilities::REFRESH_AFTER : 15 * MINUTE_IN_SECONDS);
    }

    private function schedule_entitlement_refresh(?int $delay = null): void
    {
        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings) || '' === trim((string) ($settings['api_key'] ?? ''))) {
            Capabilities::clear();
            return;
        }
        if (! function_exists('wp_next_scheduled') || ! function_exists('wp_schedule_single_event')) {
            return;
        }
        $seconds = null === $delay
            ? (Capabilities::needs_refresh() ? 60 : Capabilities::REFRESH_AFTER)
            : max(60, $delay);
        $next = wp_next_scheduled('encypher_refresh_account_entitlements');
        $target = time() + $seconds;
        if ($next) {
            if ((int) $next <= $target || ! function_exists('wp_unschedule_event')) {
                return;
            }
            wp_unschedule_event((int) $next, 'encypher_refresh_account_entitlements');
        }
        wp_schedule_single_event($target, 'encypher_refresh_account_entitlements');
    }


    /**
     * Return the organization's current rights profile defaults.
     *
     * Fetches the org tier from stored settings and derives the default AI
     * permissions (training, inference, mining) that apply when no explicit
     * per-block or per-document override exists. Used by the block editor
     * to show conflict indicators.
     */
    public function handle_org_rights_request(WP_REST_Request $request)
    {
        $settings = get_option('encypher_provenance_settings', []);
        $tier = strtolower($settings['tier'] ?? 'free');

        // Derive default AI permissions from tier
        // Bronze: all allowed, Silver: training prohibited, Gold: all prohibited
        $tier_permissions = [
            'free' => ['ai_training' => true, 'ai_inference' => true, 'data_mining' => true],
            'starter' => ['ai_training' => true, 'ai_inference' => true, 'data_mining' => true],
            'bronze' => ['ai_training' => true, 'ai_inference' => true, 'data_mining' => true],
            'professional' => ['ai_training' => false, 'ai_inference' => true, 'data_mining' => false],
            'silver' => ['ai_training' => false, 'ai_inference' => true, 'data_mining' => false],
            'enterprise' => ['ai_training' => false, 'ai_inference' => false, 'data_mining' => false],
            'gold' => ['ai_training' => false, 'ai_inference' => false, 'data_mining' => false],
        ];

        $permissions = $tier_permissions[$tier] ?? $tier_permissions['free'];
        $profile_version = $settings['rights_profile_version'] ?? '1';

        return new WP_REST_Response([
            'tier' => $tier,
            'permissions' => $permissions,
            'profile_version' => $profile_version,
            'organization_name' => $settings['organization_name'] ?? '',
        ]);
    }

    /**
     * Perform HTTP request to backend and decode JSON response.
     */
    /**
     * Guarded wrapper around wp_remote_get(), delegating to the shared
     * ApiUrlGuard::guarded_remote_get() chokepoint (see that class for why
     * this is centralized rather than duplicated per call site). Kept as an
     * instance method here since every call site in this class already uses
     * $this->guarded_remote_get(...).
     */
    private function guarded_remote_get(string $url, array $args = [])
    {
        return ApiUrlGuard::guarded_remote_get($url, $args);
    }

    /**
     * Guarded wrapper around wp_remote_post(). See guarded_remote_get().
     */
    private function guarded_remote_post(string $url, array $args = [])
    {
        return ApiUrlGuard::guarded_remote_post($url, $args);
    }

    private function call_backend(
        string $path,
        array $body,
        bool $require_auth,
        int $timeout = 20,
        string $idempotency_key = ''
    )
    {
        $settings = get_option('encypher_provenance_settings', []);
        $base_url = isset($settings['api_base_url']) ? rtrim($settings['api_base_url'], '/') : '';
        if (! $base_url) {
            return new WP_Error('missing_configuration', __('Please configure the Encypher API base URL.', 'encypher-provenance'), ['status' => 400]);
        }
        if (! ApiUrlGuard::is_safe($base_url)) {
            // Stored settings are already guarded at save time
            // (Admin::sanitize_settings()), but re-check here too: this is
            // the actual outbound-call chokepoint, and re-validating costs
            // nothing while closing any path that could otherwise persist
            // an unsafe value (a pre-guard install, a direct DB edit, a
            // future settings-write path that forgets the guard).
            return new WP_Error('unsafe_api_base_url', __('The configured Encypher API URL is not allowed (must be HTTPS and a public host).', 'encypher-provenance'), ['status' => 400]);
        }

        $url = $base_url . $path;
        $headers = [
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ];
        if ('' !== $idempotency_key) {
            if (strlen($idempotency_key) > 128 || preg_match('/[^\x20-\x7e]/', $idempotency_key)) {
                return new WP_Error('invalid_idempotency_key', __('The signing operation identity is invalid.', 'encypher-provenance'), ['status' => 500]);
            }
            $headers['Idempotency-Key'] = $idempotency_key;
        }

        $api_key = isset($settings['api_key']) ? trim((string) $settings['api_key']) : '';
        if ($require_auth && $api_key) {
            $headers['Authorization'] = 'Bearer ' . sanitize_text_field($api_key);
        } elseif ($require_auth) {
            return new WP_Error('missing_api_key', __('Please configure an Encypher API key before signing.', 'encypher-provenance'), ['status' => 401]);
        }

        $args = [
            'headers' => $headers,
            'body' => wp_json_encode($body),
            'timeout' => $timeout,
        ];

        // Retry once on transient server errors (5xx) or network-level failures.
        $response = null;
        $last_error = null;
        for ($attempt = 1; $attempt <= 2; $attempt++) {
            $response = $this->guarded_remote_post($url, $args);
            if (is_wp_error($response)) {
                $last_error = $response;
                if ($attempt < 2) {
                    sleep(2);
                }
                continue;
            }
            $attempt_code = wp_remote_retrieve_response_code($response);
            if ($attempt_code >= 500 && $attempt < 2) {
                $last_error = null;
                sleep(2);
                continue;
            }
            $last_error = null;
            break;
        }
        if ($last_error !== null) {
            return new WP_Error('http_error', $last_error->get_error_message(), ['status' => 500]);
        }

        $status_code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        $decoded = json_decode($body, true);

        if (null === $decoded && '' !== trim((string) $body)) {
            return new WP_Error('invalid_json', __('Unable to parse backend response.', 'encypher-provenance'), ['status' => 502]);
        }

        if ($status_code >= 400) {
            $message = '';
            $code = 'backend_error';
            if (is_array($decoded)) {
                if (isset($decoded['error']['message'])) {
                    $message = (string) $decoded['error']['message'];
                } elseif (isset($decoded['message'])) {
                    $message = (string) $decoded['message'];
                } elseif (isset($decoded['detail'])) {
                    // FastAPI validation errors return detail as array
                    if (is_array($decoded['detail'])) {
                        $message = wp_json_encode($decoded['detail'], JSON_PRETTY_PRINT);
                        $this->debug_log('Validation error details: ' . $message);
                    } else {
                        $message = (string) $decoded['detail'];
                    }
                }
                if (isset($decoded['error']['code'])) {
                    $code = (string) $decoded['error']['code'];
                }
            }
            if (! $message) {
                $message = $body;
            }
            /* translators: 1: HTTP status code, 2: error message returned by the backend. */
            return new WP_Error($code, sprintf(__('Backend responded with status %1$d: %2$s', 'encypher-provenance'), $status_code, $message), ['status' => $status_code]);
        }

        if (is_array($decoded) && array_key_exists('success', $decoded) && true !== $decoded['success']) {
            $message = isset($decoded['error']['message']) ? (string) $decoded['error']['message'] : (isset($decoded['message']) ? (string) $decoded['message'] : __('The Encypher API rejected the request.', 'encypher-provenance'));
            $code = isset($decoded['error']['code']) ? (string) $decoded['error']['code'] : 'api_error';
            $error_status = isset($decoded['error']['status']) ? (int) $decoded['error']['status'] : 400;
            return new WP_Error($code, $message, ['status' => $error_status]);
        }

        return is_array($decoded) ? $decoded : [];
    }


    /**
     * Probe the API server to verify it is reachable.
     *
     * Strategy (in order):
     *   1. GET /health        -- fastest, no auth needed
     *   2. GET /readyz        -- fallback lightweight probe
     *   3. GET /api/v1/account -- always routed in Traefik; 401 still proves the
     *                            API is up (gateway is routing, not 404-ing)
     *
     * Non-200 client errors (403 from Cloudflare WAF, 405 from misconfigured
     * proxies, etc.) are treated the same as 404: the probe continues to the
     * next path. Only transport-level failures and 5xx responses abort early.
     *
     * Returns decoded JSON body on 200, empty array on any other "API is up"
     * response (401/403 from account probe), or WP_Error on genuine failure.
     *
     * @param string $api_base_url Already-normalized base URL (ending with /api/v1).
     * @return array|WP_Error
     */
    private function probe_health(string $api_base_url)
    {
        if (! ApiUrlGuard::is_safe($api_base_url)) {
            return new WP_Error(
                'unsafe_api_base_url',
                __('The provided Encypher API URL is not allowed (must be HTTPS and a public host).', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        $root = preg_replace('#/api/v1/?$#', '', rtrim($api_base_url, '/'));

        // --- Phase 1: root-level health probes ---
        foreach (['/health', '/readyz'] as $probe_path) {
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin connection-test health probe; not an anonymous front-end request.
            $response = $this->guarded_remote_get($root . $probe_path, ['timeout' => 10]);

            if (is_wp_error($response)) {
                // Transport-level failure (DNS, timeout, TLS) -- abort immediately.
                return new WP_Error(
                    'connection_failed',
                    sprintf(
                        /* translators: %s: transport-level error message (DNS, timeout, TLS). */
                        __('Connection failed: %s', 'encypher-provenance'),
                        $response->get_error_message()
                    ),
                    ['status' => 500]
                );
            }

            $status_code = wp_remote_retrieve_response_code($response);

            if ($status_code === 200) {
                return json_decode(wp_remote_retrieve_body($response), true) ?: [];
            }

            // 5xx means the server is up but unhealthy -- abort with a clear message.
            if ($status_code >= 500) {
                return new WP_Error(
                    'health_check_failed',
                    /* translators: %d: HTTP status code returned by the API health probe. */
                    sprintf(__('API server error (status %d). The server may be starting up or experiencing issues.', 'encypher-provenance'), $status_code),
                    ['status' => $status_code]
                );
            }

            // 4xx (403 from Cloudflare WAF, 404 path not routed, 405, etc.)
            // -- continue to next probe. These client errors do not prove the
            // API is down; they often come from CDN/WAF layers in front of it.
            continue;
        }

        // --- Phase 2: account endpoint fallback ---
        // /api/v1/account is guaranteed to be routed by Traefik.
        // A 401/403 response proves the API gateway is up and routing correctly.
        // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin connection-test account probe; not an anonymous front-end request.
        $account_probe = $this->guarded_remote_get(rtrim($api_base_url, '/') . '/account', ['timeout' => 10]);

        if (is_wp_error($account_probe)) {
            return new WP_Error(
                'connection_failed',
                sprintf(
                    /* translators: %s: transport-level error message (DNS, timeout, TLS). */
                    __('Connection failed: %s', 'encypher-provenance'),
                    $account_probe->get_error_message()
                ),
                ['status' => 500]
            );
        }

        $status_code = wp_remote_retrieve_response_code($account_probe);

        // 2xx, 401, 403 all mean the API is reachable
        if ($status_code < 500 && $status_code !== 404) {
            return [];
        }

        return new WP_Error(
            'health_check_failed',
            /* translators: %d: HTTP status code returned by the API host probe. */
            sprintf(__('API host unreachable (status %d).', 'encypher-provenance'), $status_code),
            ['status' => $status_code]
        );
    }

    /**
     * Normalizes an API base URL to always end with /api/v1 (no trailing slash).
     * Accepts both https://api.encypher.com/ and https://api.encypher.com/api/v1.
     */
    private static function normalize_api_base_url(string $url): string
    {
        $url = rtrim(trim($url), '/');
        if ('' === $url) {
            return '';
        }
        if (! str_ends_with($url, '/api/v1')) {
            $url .= '/api/v1';
        }
        return $url;
    }

    /**
     * Detect whether an HTTP response came from Cloudflare's infrastructure
     * rather than the Encypher API, and return a user-friendly message if so.
     *
     * Cloudflare 1xxx errors (DNS conflicts, prohibited IPs, etc.) are generated
     * by Cloudflare's network layer before the request reaches our backend.
     * These cannot be fixed on our side -- the site operator needs to adjust
     * their DNS or hosting configuration.
     *
     * @param array|WP_Error $response  The raw wp_remote_* response.
     * @return string|null  User-friendly guidance if Cloudflare is the source, null otherwise.
     */
    public static function detect_cloudflare_block($response): ?string
    {
        if (is_wp_error($response)) {
            return null;
        }

        $server = strtolower(self::normalize_http_header_value(wp_remote_retrieve_header($response, 'server')));
        $cf_ray = self::normalize_http_header_value(wp_remote_retrieve_header($response, 'cf-ray'));
        $is_cloudflare = (false !== strpos($server, 'cloudflare')) || ('' !== $cf_ray);

        if (! $is_cloudflare) {
            return null;
        }

        $status_code = (int) wp_remote_retrieve_response_code($response);
        if ($status_code < 400) {
            return null;
        }

        $raw_body = wp_remote_retrieve_body($response);
        $body_lower = strtolower($raw_body);

        // Cloudflare Error 1000 series: DNS conflicts, prohibited IPs, direct IP access
        $cf_patterns = [
            'prohibited ip',
            'dns points to',
            'dns resolution error',
            'direct ip access not allowed',
            'error 1000',
            'error 1001',
            'error 1002',
            'error 1003',
            'cloudflare ray id',
            'cf-error-details',
        ];

        $is_cf_block = false;
        foreach ($cf_patterns as $pattern) {
            if (false !== strpos($body_lower, $pattern)) {
                $is_cf_block = true;
                break;
            }
        }

        if (! $is_cf_block && $status_code === 403) {
            // Generic Cloudflare 403 without a recognizable pattern --
            // still worth flagging if the server header confirms Cloudflare.
            $is_cf_block = true;
        }

        if (! $is_cf_block) {
            return null;
        }

        return __(
            'Your hosting provider\'s network is blocking the connection to the Encypher API. This usually means your site\'s DNS records conflict with Cloudflare (our API host). To connect: use the "Connect with API key" option instead -- sign in at dashboard.encypher.com, copy your API key, and paste it here. To fix permanently: ask your hosting provider to check for DNS A/CNAME records pointing to Cloudflare IPs.',
            'encypher-provenance'
        );
    }

    /**
     * WordPress may return a header as a string or as an array of repeated values.
     */
    private static function normalize_http_header_value($value): string
    {
        if (is_array($value)) {
            return implode(',', array_map('strval', $value));
        }

        if (null === $value || false === $value) {
            return '';
        }

        return (string) $value;
    }

    /**
     * Extract a human-readable error detail from an API response body.
     *
     * Checks for Cloudflare blocks first (returns user-friendly guidance),
     * then falls back to standard JSON detail/message extraction.
     *
     * @param array|WP_Error $response  The raw wp_remote_* response.
     * @param string         $fallback  Default message if nothing can be extracted.
     * @return string
     */
    private static function extract_api_error_detail($response, string $fallback = ''): string
    {
        $cf_message = self::detect_cloudflare_block($response);
        if (null !== $cf_message) {
            return $cf_message;
        }

        $raw_body = wp_remote_retrieve_body($response);
        $body = json_decode($raw_body, true);

        if (is_array($body)) {
            if (isset($body['detail']['message']) && is_string($body['detail']['message']) && '' !== $body['detail']['message']) {
                return sanitize_text_field($body['detail']['message']);
            }
            if (isset($body['detail']) && is_string($body['detail']) && '' !== $body['detail']) {
                return sanitize_text_field($body['detail']);
            }
            if (isset($body['message']) && is_string($body['message']) && '' !== $body['message']) {
                return sanitize_text_field($body['message']);
            }
        }

        return $fallback;
    }

    /**
     * Handle connection test request (server-side).
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response|WP_Error
     */
    public function handle_test_connection_request(WP_REST_Request $request)
    {
        // Use provided values from request, or fall back to saved settings
        $api_base_url = $request->get_param('api_base_url');
        $api_key = $request->get_param('api_key');

        // If not provided in request, use saved settings
        if (empty($api_base_url)) {
            $settings = get_option('encypher_provenance_settings', []);
            $api_base_url = isset($settings['api_base_url']) ? $settings['api_base_url'] : '';
            $api_key = isset($settings['api_key']) ? $settings['api_key'] : '';
        }

        $api_base_url = self::normalize_api_base_url($api_base_url);

        if (empty($api_base_url)) {
            return new WP_Error(
                'no_api_url',
                __('No API URL configured', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        $health = $this->probe_health($api_base_url);
        if (is_wp_error($health)) {
            return $health;
        }

        $health_data = $health;

        // Build response
        $result = [
            'success' => true,
            'status' => 'connected',
            'api_url' => $api_base_url,
            'health' => $health_data,
        ];

        $result['organization'] = [
            'organization_id' => 'org_demo',
            'name' => 'Demo Organization',
            'tier' => 'free'
        ];

        // Check if API key is configured
        if (!empty($api_key)) {
            $result['api_key_configured'] = true;
            $result['auth_note'] = 'API key configured (will be validated during signing)';

            $account = $this->fetch_remote_account($api_base_url, $api_key);
            if (! is_wp_error($account)) {
                $result['organization'] = [
                    'organization_id' => $account['organization_id'] ?: 'org_unknown',
                    'name' => $account['organization_name'] ?: ($account['organization_id'] ?: __('Your organization', 'encypher-provenance')),
                    'tier' => $account['tier'],
                ];
            }
        } else {
            $result['api_key_configured'] = false;
            $result['auth_note'] = 'No API key configured - using demo mode';
        }

        if (! empty($api_key) && ! empty($result['organization']['organization_id'])) {
            $this->sync_remote_wordpress_status($api_base_url, $api_key, [
                'connection_status' => 'connected',
                'site_url' => home_url('/'),
                'admin_url' => admin_url('admin.php?page=encypher'),
                'site_name' => get_bloginfo('name'),
                'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : 'production',
                'network_id' => is_multisite() ? (string) get_current_network_id() : null,
                'blog_id' => is_multisite() ? get_current_blog_id() : null,
                'is_multisite' => is_multisite(),
                'is_primary' => is_multisite() ? (get_current_blog_id() === get_main_site_id()) : true,
                'organization_id' => $result['organization']['organization_id'],
                'organization_name' => $result['organization']['name'],
                'plugin_version' => ENCYPHER_PROVENANCE_VERSION,
                'plugin_installed' => true,
                'connection_tested' => true,
                'last_connection_checked_at' => gmdate('c'),
            ]);
        }

        return new WP_REST_Response($result);
    }

    /**
     * Verify credentials and persist them without a full settings-form submit.
     * Called automatically when the user pastes an API key on the settings page.
     */
    public function handle_quick_connect_request(WP_REST_Request $request)
    {
        $api_base_url = self::normalize_api_base_url((string) $request->get_param('api_base_url'));
        $api_key      = trim((string) $request->get_param('api_key'));

        if ('' === $api_base_url || '' === $api_key) {
            return new WP_Error(
                'missing_params',
                __('API URL and API key are required.', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        $health = $this->probe_health($api_base_url);
        if (is_wp_error($health)) {
            return $health;
        }

        // Fetch account / tier info to verify the key is valid
        $account = $this->fetch_remote_account($api_base_url, $api_key);

        if (is_wp_error($account)) {
            return new WP_Error(
                'invalid_api_key',
                __('API key could not be verified. Check that it is correct and try again.', 'encypher-provenance'),
                ['status' => 401]
            );
        }

        $tier     = $account['tier'] ?? 'free';
        $features = $account['features'] ?? [];
        $usage    = $account['usage'] ?? [];
        $org_name = $account['organization_name'] ?? '';
        $org_id   = $account['organization_id'] ?? '';
        $org      = [
            'organization_id' => $org_id,
            'name'            => $org_name ?: ($org_id ?: __('Your organization', 'encypher-provenance')),
            'tier'            => $tier,
        ];

        // Persist only connection-related fields; leave all other settings untouched
        $current = get_option('encypher_provenance_settings', []);
        if (! is_array($current)) {
            $current = [];
        }
        $add_ons = $account['add_ons'] ?? [];
        $current['api_base_url']               = $api_base_url;
        $current['api_key']                    = $api_key;
        $current['tier']                       = $tier;
        $current['organization_id']            = $org['organization_id'];
        $current['organization_name']          = $org['name'];
        $current['features']                   = $features;
        $current['usage']                      = $usage;
        $current['add_ons']                    = $add_ons;
        Capabilities::store_server_projection(
            is_array($account['wordpress_entitlements'] ?? null) ? $account['wordpress_entitlements'] : [],
            (string) $org['organization_id']
        );
        $current['connection_last_status']     = 'connected';
        $current['connection_last_checked_at'] = gmdate('c');
        // A manually supplied key belongs to an existing dashboard account.
        // This explicit marker wins if the site previously used an accountless
        // ProvisionV2 credential whose origin telemetry remains first-wins.
        $current['ownership']                 = 'claimed';

        update_option('encypher_provenance_settings', $current);
        $this->mark_connected('manual');

        if (! empty($org['organization_id'])) {
            $this->sync_remote_wordpress_status($api_base_url, $api_key, [
                'connection_status' => 'connected',
                'site_url' => home_url('/'),
                'admin_url' => admin_url('admin.php?page=encypher'),
                'site_name' => get_bloginfo('name'),
                'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : 'production',
                'network_id' => is_multisite() ? (string) get_current_network_id() : null,
                'blog_id' => is_multisite() ? get_current_blog_id() : null,
                'is_multisite' => is_multisite(),
                'is_primary' => is_multisite() ? (get_current_blog_id() === get_main_site_id()) : true,
                'organization_id' => $org['organization_id'],
                'organization_name' => $org['name'],
                'plugin_version' => ENCYPHER_PROVENANCE_VERSION,
                'plugin_installed' => true,
                'connection_tested' => true,
                'last_connection_checked_at' => gmdate('c'),
            ]);
        }

        // Bust the account transient so the next full-save fetches fresh data
        $cache_key = 'encypher_account_' . md5(strtolower($api_base_url) . '|' . substr(hash('sha256', $api_key), 0, 16));
        delete_site_transient($cache_key);

        return new WP_REST_Response([
            'success'      => true,
            'status'       => 'connected',
            'api_url'      => $api_base_url,
            'tier'         => $tier,
            'organization' => $org,
        ]);
    }


    /**
     * Workspace claim state for UI gating (WS6). Pure: no I/O, unit-testable.
     *
     * A connected workspace is claimed by default. Only the explicitly
     * accountless ProvisionV2 credential is unclaimed; OAuth and manually
     * supplied API keys already belong to a dashboard account even when an
     * account email was not persisted by older plugin versions.
     *
     * @param array<string,mixed> $settings encypher_provenance_settings option.
     */
    public static function workspace_claim_state(array $settings): string
    {
        $api_key = isset($settings['api_key']) ? trim((string) $settings['api_key']) : '';
        if ('' === $api_key) {
            return 'disconnected';
        }

        if ('' !== trim((string) ($settings['account_email'] ?? ''))
            || 'claimed' === (string) ($settings['ownership'] ?? '')) {
            return 'claimed';
        }

        $is_accountless_provision = 'provision_v2' === (string) ($settings['connected_method'] ?? '');

        return $is_accountless_provision ? 'unclaimed' : 'claimed';
    }

    /**
     * Activation methods recognized by the funnel telemetry.
     */
    private const FUNNEL_METHODS = ['provision_v2', 'oauth', 'manual'];

    /**
     * Record how and when this site first connected, for the activation
     * funnel. Idempotent and first-wins: a later reconnect never rewrites the
     * origin method or timestamp. The merge-sanitizer preserves these keys.
     *
     * @param string $method One of self::FUNNEL_METHODS.
     */
    private function mark_connected(string $method): void
    {
        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }

        $changed = false;
        $existing = isset($settings['connected_method']) ? (string) $settings['connected_method'] : '';
        if (! in_array($existing, self::FUNNEL_METHODS, true) && in_array($method, self::FUNNEL_METHODS, true)) {
            $settings['connected_method'] = $method;
            $changed = true;
        }
        if (empty($settings['connected_at'])) {
            $settings['connected_at'] = gmdate('c');
            $changed = true;
        }
        if ($changed) {
            update_option('encypher_provenance_settings', $settings);
        }
    }

    /**
     * Build the additive activation-funnel object for the status-sync payload,
     * derived purely from existing local state. No raw counts leave the site:
     * signed content is reported only as a coarse bucket.
     *
     * @param array<string,mixed> $settings     Plugin settings.
     * @param int|null            $signed_total Precomputed signed count, or null
     *                                          to count now (skipped in unit shims).
     * @return array<string,mixed>
     */
    private function build_funnel_payload(array $settings, ?int $signed_total = null): array
    {
        if (null === $signed_total) {
            $signed_total = $this->count_signed_posts();
        }
        $method = isset($settings['connected_method']) ? (string) $settings['connected_method'] : '';
        if (! in_array($method, self::FUNNEL_METHODS, true)) {
            $method = 'unknown';
        }
        return [
            'connected_method'    => $method,
            'connected_at'        => $this->funnel_nonempty($settings, 'connected_at'),
            'first_signed_at'     => $this->funnel_nonempty($settings, 'first_signed_at'),
            'signed_total_bucket' => self::signed_total_bucket((int) $signed_total),
            'claimed'             => 'claimed' === self::workspace_claim_state($settings),
            'claimed_at'          => $this->funnel_nonempty($settings, 'workspace_claimed_at'),
        ];
    }

    /**
     * A non-empty settings string, or null. Keeps the funnel timestamps as
     * explicit nulls rather than empty strings.
     */
    private function funnel_nonempty(array $settings, string $key): ?string
    {
        return isset($settings[$key]) && '' !== (string) $settings[$key] ? (string) $settings[$key] : null;
    }

    /**
     * Coarse signed-content bucket for the funnel (never a raw count).
     */
    private static function signed_total_bucket(int $count): string
    {
        if ($count <= 0) {
            return '0';
        }
        if (1 === $count) {
            return '1';
        }
        if ($count <= 9) {
            return '2-9';
        }
        if ($count <= 49) {
            return '10-49';
        }
        return '50plus';
    }

    /**
     * Count posts this plugin has marked as signed. Bounded ids-only query.
     */
    private function count_signed_posts(): int
    {
        if (! function_exists('get_posts')) {
            return 0;
        }
        $ids = get_posts([
            'post_type' => 'any',
            'post_status' => 'any',
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin/cron funnel count of signed posts by the marker flag; not a front-end query.
            'meta_key' => '_encypher_marked',
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- admin/cron funnel count of signed posts by the marker flag; not a front-end query.
            'meta_value' => true,
            'fields' => 'ids',
            'posts_per_page' => -1,
            // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- deterministic funnel count; deliberately bypasses query filters.
            'suppress_filters' => true,
        ]);
        return is_array($ids) ? count($ids) : 0;
    }

    /**
     * Claim this site's ProvisionV2 site-activated workspace for a real owner (WS6).
     *
     * Issues a .well-known challenge token (served by WellKnown), then asks the
     * Encypher API to verify domain + API-key control and transfer the synthetic
     * org's ownership to the supplied email.
     */
    public function handle_claim_request(WP_REST_Request $request)
    {
        $email = sanitize_email((string) $request->get_param('email'));
        if ('' === $email || ! is_email($email)) {
            return new \WP_Error('invalid_email', __('Please enter a valid email address.', 'encypher-provenance'), ['status' => 400]);
        }

        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }

        $api_key = isset($settings['api_key']) ? (string) $settings['api_key'] : '';
        if ('' === $api_key) {
            return new \WP_Error('not_connected', __('Activate Provenance before claiming this workspace.', 'encypher-provenance'), ['status' => 400]);
        }

        $api_base_url = isset($settings['api_base_url']) ? self::normalize_api_base_url((string) $settings['api_base_url']) : 'https://api.encypher.com/api/v1';
        $site_url = home_url('/');
        $domain = wp_parse_url($site_url, PHP_URL_HOST) ?: '';
        $install_id = $this->ensure_local_install_id($settings);

        // Issue + serve a fresh .well-known challenge token (domain-control proof).
        $token = wp_generate_password(40, false);
        $settings[WellKnown::SETTINGS_KEY] = $token;
        update_option('encypher_provenance_settings', $settings);

        $response = $this->guarded_remote_post($api_base_url . '/integrations/wordpress/claim', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin install-claim request; not an anonymous front-end request.
            'timeout' => 30,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $api_key,
            ],
            'body' => wp_json_encode([
                'domain' => $domain,
                'email' => $email,
                'well_known_token' => $token,
                'install_id' => $install_id,
            ]),
        ]);

        if (is_wp_error($response)) {
            return $response;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        if ($code < 200 || $code >= 300 || ! is_array($body) || empty($body['success'])) {
            $detail = self::extract_api_error_detail($response, 'Workspace claim failed');
            return new \WP_Error('claim_failed', $detail, ['status' => $code ?: 500]);
        }

        // Record the verified owner so the UI reflects the claimed state.
        $settings['account_email'] = $email;
        $settings['workspace_claimed_at'] = gmdate('c');
        $settings['ownership'] = 'claimed';
        update_option('encypher_provenance_settings', $settings);

        return new \WP_REST_Response([
            'success' => true,
            'data' => [
                'account_email' => $email,
                'account_created' => ! empty($body['account_created']),
                'already_claimed' => ! empty($body['already_claimed']),
                'organization_id' => isset($body['organization_id'])
                    ? (string) $body['organization_id']
                    : (isset($settings['organization_id']) ? (string) $settings['organization_id'] : ''),
            ],
        ]);
    }


    public function handle_connect_poll_request(WP_REST_Request $request)
    {
        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }

        $session_id = isset($settings['connect_session_id']) ? trim((string) $settings['connect_session_id']) : '';
        $redemption_token = isset($settings['connect_redemption_token']) ? trim((string) $settings['connect_redemption_token']) : '';
        $api_base_url = isset($settings['api_base_url']) ? self::normalize_api_base_url((string) $settings['api_base_url']) : 'https://api.encypher.com/api/v1';
        if ('' === $session_id) {
            return new WP_Error('missing_connect_session', __('No active WordPress connect session found.', 'encypher-provenance'), ['status' => 404]);
        }
        if ('' === $redemption_token) {
            return new WP_Error('missing_connect_redemption', __('No credential redemption token was found for this connection.', 'encypher-provenance'), ['status' => 404]);
        }

        $response = $this->guarded_remote_get($api_base_url . '/integrations/wordpress/connect/session/' . rawurlencode($session_id), [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin connect-flow session poll; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
            ],
        ]);

        if (is_wp_error($response)) {
            return $response;
        }

        $status_code = (int) wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        // Terminal: the backend no longer knows this connect session (expired
        // or purged). Clear the stored session so the next connect attempt
        // starts fresh, and report a terminal status the settings screen can
        // stop polling on.
        if (in_array($status_code, [404, 410], true) || $this->connect_session_expired_body($body)) {
            $settings['connect_session_id'] = '';
            $settings['connect_redemption_token'] = '';
            $settings['connect_email'] = '';
            update_option('encypher_provenance_settings', $settings);
            return rest_ensure_response(['status' => 'expired']);
        }
        if (! is_array($body) || empty($body['success']) || ! isset($body['data']) || ! is_array($body['data'])) {
            return new WP_Error('connect_poll_failed', __('Unable to check WordPress connect status.', 'encypher-provenance'), ['status' => 500]);
        }

        $data = $body['data'];
        $completed = ($data['status'] ?? '') === 'completed';
        if ($completed) {
            $redeem_response = $this->guarded_remote_post($api_base_url . '/integrations/wordpress/connect/redeem', [
                // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- one-time admin connect credential redemption.
                'timeout' => 15,
                'headers' => [
                    'Accept' => 'application/json',
                    'Content-Type' => 'application/json',
                ],
                'body' => wp_json_encode([
                    'session_id' => $session_id,
                    'redemption_token' => $redemption_token,
                ]),
            ]);
            if (is_wp_error($redeem_response)) {
                return $redeem_response;
            }
            $redeem_body = json_decode(wp_remote_retrieve_body($redeem_response), true);
            if (! is_array($redeem_body) || empty($redeem_body['success']) || empty($redeem_body['data']['api_key'])) {
                return new WP_Error('connect_redeem_failed', __('Unable to redeem the approved WordPress credential.', 'encypher-provenance'), ['status' => 500]);
            }
            $data = $redeem_body['data'];
            $settings['api_key'] = sanitize_text_field((string) $data['api_key']);
            $settings['organization_id'] = isset($data['organization_id']) ? sanitize_text_field((string) $data['organization_id']) : '';
            $settings['organization_name'] = isset($data['organization_name']) ? sanitize_text_field((string) $data['organization_name']) : '';
            $settings['connect_session_id'] = '';
            $settings['connect_redemption_token'] = '';
            $settings['connect_email'] = '';
            $settings['ownership'] = 'claimed';

            $account = $this->fetch_remote_account($api_base_url, $settings['api_key']);
            if (! is_wp_error($account)) {
                $settings['tier'] = $account['tier'] ?? 'free';
                $settings['features'] = $account['features'] ?? [];
                $settings['add_ons'] = $account['add_ons'] ?? [];
                Capabilities::store_server_projection(
                    is_array($account['wordpress_entitlements'] ?? null) ? $account['wordpress_entitlements'] : [],
                    (string) ($account['organization_id'] ?? $settings['organization_id'])
                );
            } else {
                Capabilities::clear();
            }

            update_option('encypher_provenance_settings', $settings);
            $this->mark_connected('oauth');

            $this->sync_remote_wordpress_status($api_base_url, $settings['api_key'], [
                'connection_status' => 'connected',
                'organization_id' => $settings['organization_id'],
                'organization_name' => $settings['organization_name'],
                'plugin_installed' => true,
                'connection_tested' => true,
                'last_connection_checked_at' => gmdate('c'),
            ]);
        } else {
            // A pending or otherwise incomplete approval can never forward a
            // credential, even if the upstream response contains one.
            unset($data['api_key']);
        }

        return new WP_REST_Response([
            'success' => true,
            'data' => $data,
        ]);
    }

    /**
     * Whether a decoded /connect/session poll body reports the session as
     * unknown or expired (terminal — polling should stop).
     *
     * @param mixed $body Decoded JSON response body.
     */
    private function connect_session_expired_body($body): bool
    {
        if (! is_array($body)) {
            return false;
        }

        $status = '';
        if (isset($body['data']['status'])) {
            $status = strtolower((string) $body['data']['status']);
        } elseif (isset($body['status']) && is_string($body['status'])) {
            $status = strtolower($body['status']);
        }
        if (in_array($status, ['expired', 'not_found', 'unknown'], true)) {
            return true;
        }

        $message = '';
        foreach (['error', 'detail', 'message'] as $key) {
            if (isset($body[$key]) && is_string($body[$key]) && '' !== $body[$key]) {
                $message = strtolower($body[$key]);
                break;
            }
        }
        if ('' === $message) {
            return false;
        }

        return false !== strpos($message, 'expired')
            || false !== strpos($message, 'not found')
            || false !== strpos($message, 'unknown session');
    }

    public function handle_oauth_start_request(WP_REST_Request $request)
    {
        $tos_accepted = $request->get_param('tos_accepted');
        if (! in_array($tos_accepted, [true, 'true', 1, '1'], true)) {
            return new WP_Error(
                'tos_not_accepted',
                __('You must accept the Encypher Terms of Service and Privacy Policy before activating provenance.', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }

        $api_base_url = isset($settings['api_base_url']) ? self::normalize_api_base_url((string) $settings['api_base_url']) : 'https://api.encypher.com/api/v1';
        $site_url = home_url('/');
        $domain = wp_parse_url($site_url, PHP_URL_HOST) ?: '';
        $install_id = $this->ensure_local_install_id($settings);

        $response = $this->guarded_remote_post($api_base_url . '/integrations/wordpress/oauth/start', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin OAuth start; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
            ],
            'body' => wp_json_encode([
                'domain' => $domain,
                'site_url' => $site_url,
                'site_name' => get_bloginfo('name'),
                'install_id' => $install_id,
            ]),
        ]);

        if (is_wp_error($response)) {
            return $response;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (! is_array($body) || empty($body['success']) || ! isset($body['data']['session_id'])) {
            $cf_msg = self::detect_cloudflare_block($response);
            if ($cf_msg) {
                return new WP_Error('cloudflare_block', $cf_msg, ['status' => 502]);
            }
            return new WP_Error('oauth_start_failed', __('Unable to start OAuth connect flow.', 'encypher-provenance'), ['status' => 500]);
        }

        update_option('encypher_tos_accepted', [
            'version' => self::TOS_VERSION,
            'accepted_at' => gmdate('c'),
            'user_id' => get_current_user_id(),
        ]);

        $settings['connect_session_id'] = sanitize_text_field((string) $body['data']['session_id']);
        $settings['connect_redemption_token'] = sanitize_text_field((string) ($body['data']['redemption_token'] ?? ''));
        $settings['install_id'] = $install_id;
        update_option('encypher_provenance_settings', $settings);

        return new WP_REST_Response([
            'success' => true,
            'data' => [
                'session_id' => $settings['connect_session_id'],
                'connect_url' => $body['data']['connect_url'] ?? '',
            ],
        ]);
    }

    /**
     * Cancel an in-flight OAuth connection session and return to Not connected.
     *
     * Bounded-recovery endpoint (PRD T2.8): the settings JS calls this when the
     * user clicks Cancel or the connection poll times out, so the disconnected
     * screen never hangs in an indefinite waiting state. Clears only the
     * transient connect-session pointer; every stored setting (API key, org,
     * tier, rights) is preserved.
     */
    public function handle_oauth_cancel_request(WP_REST_Request $request)
    {
        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }
        unset($settings['connect_session_id']);
        unset($settings['connect_redemption_token']);
        $settings['connection_last_status'] = 'disconnected';
        update_option('encypher_provenance_settings', $settings);

        return new WP_REST_Response([
            'success' => true,
            'data'    => ['status' => 'cancelled'],
        ]);
    }

    private function fetch_remote_account(string $api_base_url, string $api_key)
    {
        $base = self::normalize_api_base_url($api_base_url);
        if ('' === $base || '' === trim($api_key)) {
            return new \WP_Error('invalid_configuration', __('Missing API base URL or API key.', 'encypher-provenance'));
        }
        if (! ApiUrlGuard::is_safe($base)) {
            return new \WP_Error(
                'unsafe_api_base_url',
                __('The provided Encypher API URL is not allowed (must be HTTPS and a public host).', 'encypher-provenance')
            );
        }

        $account_url = $base . '/account';
        $cache_key = 'encypher_account_' . md5(strtolower($base) . '|' . substr(hash('sha256', $api_key), 0, 16));
        $cached = get_site_transient($cache_key);
        if (is_array($cached)) {
            return $cached;
        }

        $response = $this->guarded_remote_get($account_url, [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin account fetch; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
        ]);

        if (is_wp_error($response)) {
            return $response;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code >= 400) {
            $cf_message = self::detect_cloudflare_block($response);
            if (null !== $cf_message) {
                return new \WP_Error('cloudflare_block', $cf_message, ['status' => $status_code]);
            }
            /* translators: %d: HTTP status code returned by the stats request. */
            return new \WP_Error('stats_http_error', sprintf(__('Stats request failed with status %d.', 'encypher-provenance'), $status_code));
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (! is_array($body)) {
            return new \WP_Error('account_parse_error', __('Unable to parse account response.', 'encypher-provenance'));
        }

        $data = isset($body['data']) && is_array($body['data']) ? $body['data'] : [];
        $tier = Admin::apply_account_tier($data['tier'] ?? 'free');

        $add_ons = isset($data['add_ons']) && is_array($data['add_ons']) ? $data['add_ons'] : [];
        $features = isset($data['features']) && is_array($data['features']) ? $data['features'] : [];
        $wordpress_entitlements = isset($data['wordpress_entitlements']) && is_array($data['wordpress_entitlements'])
            ? $data['wordpress_entitlements']
            : [];

        $account = [
            'tier' => $tier,
            'organization_id' => isset($data['organization_id']) ? sanitize_text_field((string) $data['organization_id']) : '',
            'organization_name' => isset($data['organization_name']) ? sanitize_text_field((string) $data['organization_name']) : (isset($data['name']) ? sanitize_text_field((string) $data['name']) : ''),
            'add_ons' => $add_ons,
            'features' => $features,
            'wordpress_entitlements' => $wordpress_entitlements,
        ];

        set_site_transient($cache_key, $account, 15 * MINUTE_IN_SECONDS);

        return $account;
    }

    private function ensure_local_install_id(array $settings): string
    {
        $install_id = isset($settings['install_id']) ? trim((string) $settings['install_id']) : '';
        if ('' !== $install_id) {
            return $install_id;
        }

        $install_id = 'wpi_' . substr(md5(home_url('/') . '|' . admin_url('admin.php?page=encypher') . '|' . wp_generate_uuid4()), 0, 16);
        $settings['install_id'] = $install_id;
        update_option('encypher_provenance_settings', $settings);
        return $install_id;
    }

    private function build_install_payload(array $settings): array
    {
        return [
            'install_id' => $this->ensure_local_install_id($settings),
            'site_url' => home_url('/'),
            'admin_url' => admin_url('admin.php?page=encypher'),
            'site_name' => get_bloginfo('name'),
            'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : 'production',
            'network_id' => is_multisite() ? (string) get_current_network_id() : null,
            'blog_id' => is_multisite() ? get_current_blog_id() : null,
            'is_multisite' => is_multisite(),
            'is_primary' => is_multisite() ? (get_current_blog_id() === get_main_site_id()) : true,
            'plugin_version' => ENCYPHER_PROVENANCE_VERSION,
            'admin_email' => get_option('admin_email'),
        ];
    }

    private function register_remote_wordpress_install(string $api_base_url, string $api_key, array $settings): string
    {
        $base = self::normalize_api_base_url($api_base_url);
        if ('' === $base || '' === trim($api_key)) {
            return isset($settings['install_id']) ? (string) $settings['install_id'] : '';
        }

        $payload = $this->build_install_payload($settings);
        $response = $this->guarded_remote_post($base . '/integrations/wordpress/register-install', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin install registration; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
            'body' => wp_json_encode($payload),
        ]);

        if (! is_wp_error($response)) {
            $body = json_decode(wp_remote_retrieve_body($response), true);
            if (is_array($body) && isset($body['data']['registered_install']['install_id'])) {
                $settings['install_id'] = sanitize_text_field((string) $body['data']['registered_install']['install_id']);
                update_option('encypher_provenance_settings', $settings);
                return $settings['install_id'];
            }
        }

        return $payload['install_id'];
    }

    private function sync_remote_wordpress_status(string $api_base_url, string $api_key, array $payload): void
    {
        $base = self::normalize_api_base_url($api_base_url);
        if ('' === $base || '' === trim($api_key)) {
            return;
        }

        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            $settings = [];
        }
        $install_id = $this->register_remote_wordpress_install($api_base_url, $api_key, $settings);
        $settings['install_id'] = $install_id;
        $signed_total = isset($payload['signed_post_count']) ? (int) $payload['signed_post_count'] : null;
        $payload = array_merge($this->build_install_payload($settings), $payload, [
            'install_id' => $install_id,
            'funnel' => $this->build_funnel_payload($settings, $signed_total),
        ]);

        $response = $this->guarded_remote_post($base . '/integrations/wordpress/status-sync', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin/cron status sync; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
            'body' => wp_json_encode($payload),
        ]);

        if (is_wp_error($response)) {
            $this->debug_log('WordPress status sync failed: ' . $response->get_error_message());
            return;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code >= 400) {
            $this->debug_log('WordPress status sync returned status ' . $status_code);
            return;
        }

        $this->process_remote_wordpress_actions($base, $api_key, $install_id);
    }

    private function sync_remote_wordpress_verification_event(string $api_base_url, string $api_key, array $payload): void
    {
        $base = self::normalize_api_base_url($api_base_url);
        if ('' === $base || '' === trim($api_key)) {
            return;
        }

        $this->guarded_remote_post($base . '/integrations/wordpress/verification-event', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- background verification-event report; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
            'body' => wp_json_encode($payload),
        ]);
    }

    private function process_remote_wordpress_actions(string $api_base_url, string $api_key, string $install_id): void
    {
        if ('' === $install_id) {
            return;
        }

        $response = $this->guarded_remote_post($api_base_url . '/integrations/wordpress/' . rawurlencode($install_id) . '/actions/pull', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin/cron action pull; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
        ]);

        if (is_wp_error($response)) {
            return;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        $actions = isset($body['data']['actions']) && is_array($body['data']['actions']) ? $body['data']['actions'] : [];
        foreach ($actions as $action) {
            if (! is_array($action) || empty($action['action_id']) || empty($action['action_type'])) {
                continue;
            }

            $result = $this->execute_remote_wordpress_action((string) $action['action_type'], (string) ($action['note'] ?? ''), $api_base_url, $api_key);
            $this->ack_remote_wordpress_action(
                $api_base_url,
                $api_key,
                $install_id,
                (string) $action['action_id'],
                [
                    'status' => $result['status'],
                    'result_message' => $result['result_message'],
                    'completed_at' => gmdate('c'),
                ]
            );
        }
    }

    private function execute_remote_wordpress_action(string $action_type, string $note, string $api_base_url, string $api_key): array
    {
        if ('refresh_status' === $action_type) {
            return [
                'status' => 'completed',
                'result_message' => $note !== '' ? $note : 'Status refresh acknowledged by WordPress.',
            ];
        }

        if ('test_connection' === $action_type) {
            $health = $this->probe_health($api_base_url);
            if (is_wp_error($health)) {
                return [
                    'status' => 'failed',
                    'result_message' => $health->get_error_message(),
                ];
            }

            $settings = get_option('encypher_provenance_settings', []);
            if (is_array($settings)) {
                $settings['connection_last_status'] = 'connected';
                $settings['connection_last_checked_at'] = gmdate('c');
                update_option('encypher_provenance_settings', $settings);
            }

            return [
                'status' => 'completed',
                'result_message' => 'Connection test completed successfully.',
            ];
        }

        return [
            'status' => 'failed',
            'result_message' => 'Unsupported remote action: ' . $action_type,
        ];
    }

    private function ack_remote_wordpress_action(string $api_base_url, string $api_key, string $install_id, string $action_id, array $payload): void
    {
        $this->guarded_remote_post($api_base_url . '/integrations/wordpress/' . rawurlencode($install_id) . '/actions/' . rawurlencode($action_id) . '/ack', [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin/cron action ack; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
            'body' => wp_json_encode($payload),
        ]);
    }

    private function resolve_usage_snapshot($settings, bool $refresh_remote = false): array
    {
        if (!is_array($settings)) {
            $settings = [];
        }

        $tier = isset($settings['tier']) ? (string) $settings['tier'] : 'free';
        $usage = $this->normalize_usage_snapshot(
            isset($settings['usage']) && is_array($settings['usage']) ? $settings['usage'] : [],
            $tier
        );

        if (! $refresh_remote) {
            return $usage;
        }

        $api_key = isset($settings['api_key']) ? trim((string) $settings['api_key']) : '';
        $api_base_url = isset($settings['api_base_url']) ? trim((string) $settings['api_base_url']) : '';
        if ('' === $api_key || '' === $api_base_url) {
            return $usage;
        }

        $remote_usage = $this->fetch_remote_usage_quota($api_base_url, $api_key, $tier, [
            'usage' => $usage,
        ]);

        if (isset($remote_usage['api_calls']) && is_array($remote_usage['api_calls'])) {
            return $remote_usage;
        }

        return $usage;
    }

    /**
     * Free-tier signing quota pre-flight. Returns a WP_Error (HTTP 402) when
     * the monthly allowance is demonstrably exhausted; null otherwise. Fails
     * open: an unavailable or unlimited usage snapshot never blocks signing.
     * Shares fetch_remote_usage_quota's transient cache with status polling.
     */
    private function quota_exhausted_error(array $settings): ?WP_Error
    {
        $tier = isset($settings['tier']) ? (string) $settings['tier'] : 'free';
        if ('free' !== $tier) {
            return null;
        }

        $usage = $this->resolve_usage_snapshot($settings, true);
        if (! isset($usage['api_calls']) || ! is_array($usage['api_calls'])) {
            return null;
        }
        $api_calls = $usage['api_calls'];
        if (! empty($api_calls['is_unlimited'])) {
            return null;
        }

        $limit = isset($api_calls['limit']) ? (int) $api_calls['limit'] : 0;
        $used = isset($api_calls['used']) ? (int) $api_calls['used'] : 0;
        if ($limit <= 0 || $used < $limit) {
            return null;
        }

        return new WP_Error(
            'usage_limit_reached',
            sprintf(
                /* translators: 1: signatures used this month, 2: monthly limit. */
                __('Monthly signing limit reached (%1$d of %2$d used). Upgrade your plan or wait for the monthly reset.', 'encypher-provenance'),
                $used,
                $limit
            ),
            ['status' => 402]
        );
    }

    /**
     * Normalize a raw `_encypher_last_sign_error` meta value to the canonical
     * array('code','message','time') shape, or null when absent or malformed.
     * Tolerates the legacy ErrorLog shape (error_code/timestamp).
     *
     * @param mixed $meta Raw meta value as returned by get_post_meta().
     */
    private function normalize_last_sign_error($meta): ?array
    {
        if (is_string($meta) && '' !== trim($meta)) {
            return [
                'code'    => 'media_signing_failed',
                'message' => $meta,
                'time'    => 0,
            ];
        }
        if (! is_array($meta) || empty($meta['message'])) {
            return null;
        }

        $code = '';
        if (! empty($meta['code'])) {
            $code = (string) $meta['code'];
        } elseif (! empty($meta['error_code'])) {
            $code = (string) $meta['error_code'];
        }
        if ('' === $code) {
            return null;
        }

        $time = 0;
        if (isset($meta['time']) && is_numeric($meta['time'])) {
            $time = (int) $meta['time'];
        } elseif (! empty($meta['timestamp'])) {
            $time = (int) strtotime((string) $meta['timestamp']);
        }

        return [
            'code'    => $code,
            'message' => (string) $meta['message'],
            'time'    => $time,
        ];
    }

    private function record_attachment_sign_error(int $attachment_id, string $code, string $message): void
    {
        update_post_meta($attachment_id, '_encypher_last_sign_error', [
            'code'    => $code,
            'message' => $message,
            'time'    => time(),
        ]);
    }

    private function fetch_remote_usage_quota(string $api_base_url, string $api_key, string $tier, array $fallback = []): array
    {
        $fallback_usage = isset($fallback['usage']) && is_array($fallback['usage'])
            ? $fallback['usage']
            : [];
        $normalized_fallback = $this->normalize_usage_snapshot($fallback_usage, $tier);
        $base = rtrim((string) $api_base_url, '/');
        if ('' === $base || '' === trim($api_key)) {
            return $normalized_fallback;
        }

        $cache_key = 'encypher_usage_' . md5(strtolower($base) . '|' . substr(hash('sha256', $api_key), 0, 16));
        $cached = get_site_transient($cache_key);
        if (is_array($cached) && isset($cached['api_calls']) && is_array($cached['api_calls'])) {
            return $cached;
        }

        $quota_url = $base . '/account/quota';
        $response = $this->guarded_remote_get($quota_url, [
            // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin quota fetch; not an anonymous front-end request.
            'timeout' => 15,
            'headers' => [
                'Accept' => 'application/json',
                'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
            ],
        ]);

        if (is_wp_error($response)) {
            return $normalized_fallback;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code >= 400) {
            return $normalized_fallback;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (! is_array($body)) {
            return $normalized_fallback;
        }

        $data = isset($body['data']) && is_array($body['data']) ? $body['data'] : [];
        $metrics = isset($data['metrics']) && is_array($data['metrics']) ? $data['metrics'] : [];
        $api_calls = isset($metrics['api_calls']) && is_array($metrics['api_calls']) ? $metrics['api_calls'] : [];

        if (empty($api_calls)) {
            return $normalized_fallback;
        }

        $resolved_usage = $this->normalize_usage_snapshot([
            'api_calls' => [
                'used' => isset($api_calls['used']) ? (int) $api_calls['used'] : 0,
                'limit' => isset($api_calls['limit']) ? (int) $api_calls['limit'] : ('free' === $tier ? 100000 : -1),
                'remaining' => isset($api_calls['remaining']) ? (int) $api_calls['remaining'] : 0,
                'percentage_used' => isset($api_calls['percentage_used']) ? (float) $api_calls['percentage_used'] : 0,
                'is_unlimited' => isset($api_calls['limit']) ? ((int) $api_calls['limit'] < 0) : ('free' !== $tier),
                'reset_date' => isset($data['reset_date']) ? sanitize_text_field((string) $data['reset_date']) : '',
                'period_end' => isset($data['period_end']) ? sanitize_text_field((string) $data['period_end']) : '',
            ],
        ], $tier);

        // Keep this cache short so editor-sidebar status checks remain close to API truth.
        set_site_transient($cache_key, $resolved_usage, 30);

        return $resolved_usage;
    }

    private function normalize_usage_snapshot(array $usage, string $tier): array
    {
        // Free-tier fallback mirrors the backend abuse guardrail (100k/month);
        // signing is unlimited for normal publishing use (June 2026 decision).
        $default_limit = 'free' === $tier ? 100000 : -1;
        $api_calls = isset($usage['api_calls']) && is_array($usage['api_calls']) ? $usage['api_calls'] : $usage;

        $used = isset($api_calls['used']) ? max(0, (int) $api_calls['used']) : 0;
        $limit = isset($api_calls['limit']) ? (int) $api_calls['limit'] : $default_limit;
        $is_unlimited = isset($api_calls['is_unlimited']) ? (bool) $api_calls['is_unlimited'] : ($limit < 0);

        if ($is_unlimited) {
            $limit = -1;
            $remaining = -1;
            $percentage_used = 0.0;
        } else {
            if ($limit <= 0) {
                $limit = $default_limit > 0 ? $default_limit : 100000;
            }
            $remaining = isset($api_calls['remaining'])
                ? max(0, (int) $api_calls['remaining'])
                : max(0, $limit - $used);
            $percentage_used = isset($api_calls['percentage_used'])
                ? (float) $api_calls['percentage_used']
                : ($limit > 0 ? round(($used / $limit) * 100, 2) : 0.0);
        }

        if (! $is_unlimited) {
            $percentage_used = min(100.0, max(0.0, $percentage_used));
        }

        return [
            'api_calls' => [
                'used' => $used,
                'limit' => $limit,
                'remaining' => $remaining,
                'percentage_used' => $percentage_used,
                'is_unlimited' => $is_unlimited,
                'reset_date' => isset($api_calls['reset_date']) ? sanitize_text_field((string) $api_calls['reset_date']) : '',
                'period_end' => isset($api_calls['period_end']) ? sanitize_text_field((string) $api_calls['period_end']) : '',
            ],
        ];
    }

    /**
     * Persist a media operation before outbound signing. A failed transport
     * leaves the record in place so a worker retry reuses the same key. A
     * successful generation clears it, making a later re-sign a new operation.
     *
     * @return array{logical_asset_id:string,operation_key:string,metadata:array<string,mixed>,hash_plan_attempt:int,hash_prepared_state:array<string,mixed>|null}|WP_Error
     */
    private function claim_media_inventory_contract(
        int $attachment_id,
        string $file_path,
        string $mime_type,
        array $media_context,
        array $settings
    ) {
        $install_id = trim((string) ($settings['install_id'] ?? ''));
        if ('' === $install_id) {
            return new WP_Error('invalid_signing_identity', __('A persistent WordPress installation identity is required for media signing.', 'encypher-provenance'));
        }
        $file_digest = hash_file('sha256', $file_path);
        if (! is_string($file_digest) || '' === $file_digest) {
            return new WP_Error('media_digest_failed', __('The media operation could not be identified before signing.', 'encypher-provenance'));
        }
        $request_fingerprint = hash('sha256', wp_json_encode([
            'file_digest' => $file_digest,
            'mime_type' => $mime_type,
            'context' => $media_context,
        ]));
        $meta_key = '_encypher_media_operation_pending';
        $pending = get_post_meta($attachment_id, $meta_key, true);
        if (
            is_array($pending)
            && hash_equals($request_fingerprint, (string) ($pending['request_fingerprint'] ?? ''))
            && '' !== (string) ($pending['generation'] ?? '')
        ) {
            $generation = (string) $pending['generation'];
            $plan_attempt = $pending['hash_plan_attempt'] ?? 0;
            if (! is_int($plan_attempt) || $plan_attempt < 0 || $plan_attempt > 1) {
                return new WP_Error('operation_persistence_failed', __('The media signing operation has invalid prepared-plan state.', 'encypher-provenance'));
            }
            $prepared_state = $pending['hash_prepared_state'] ?? null;
            if (null !== $prepared_state && ! is_array($prepared_state)) {
                return new WP_Error('operation_persistence_failed', __('The media signing operation has invalid prepared-phase state.', 'encypher-provenance'));
            }
            if (! array_key_exists('hash_plan_attempt', $pending)) {
                $claimed_pending = $pending;
                $pending['hash_plan_attempt'] = 0;
                $persisted = update_post_meta($attachment_id, $meta_key, $pending, $claimed_pending);
                if (false === $persisted && get_post_meta($attachment_id, $meta_key, true) !== $pending) {
                    return new WP_Error('operation_persistence_failed', __('The media signing operation could not be persisted.', 'encypher-provenance'));
                }
            }
        } else {
            $generation = wp_generate_uuid4();
            $plan_attempt = 0;
            $prepared_state = null;
            $pending = [
                'request_fingerprint' => $request_fingerprint,
                'generation' => $generation,
                'hash_plan_attempt' => $plan_attempt,
            ];
            $persisted = update_post_meta($attachment_id, $meta_key, $pending);
            if (false === $persisted && get_post_meta($attachment_id, $meta_key, true) !== $pending) {
                return new WP_Error('operation_persistence_failed', __('The media signing operation could not be persisted.', 'encypher-provenance'));
            }
        }

        try {
            return SignRequestBuilder::build_wordpress_inventory_contract($settings, $attachment_id, $generation)
                + [
                    'hash_plan_attempt' => $plan_attempt,
                    'hash_prepared_state' => $prepared_state,
                ];
        } catch (\InvalidArgumentException $error) {
            return new WP_Error('invalid_signing_identity', $error->getMessage());
        }
    }

    /**
     * Persist the sole allowed fresh-plan transition before issuing its
     * prepare request. A later worker can then replay that exact plan key.
     */
    private function advance_media_hash_plan_attempt(
        int $attachment_id,
        string $operation_key,
        int $current_attempt
    ) {
        $meta_key = '_encypher_media_operation_pending';
        $pending = get_post_meta($attachment_id, $meta_key, true);
        $persisted_attempt = is_array($pending) ? ($pending['hash_plan_attempt'] ?? 0) : null;
        if (
            ! is_array($pending)
            || ! is_int($persisted_attempt)
            || $persisted_attempt !== $current_attempt
            || ! hash_equals($operation_key, (string) ($pending['generation'] ?? ''))
        ) {
            return new WP_Error('operation_persistence_failed', __('The media signing operation changed before its prepared plan could advance.', 'encypher-provenance'));
        }

        $claimed_pending = $pending;
        $pending['hash_plan_attempt'] = $current_attempt + 1;
        unset($pending['hash_prepared_state']);
        $persisted = update_post_meta($attachment_id, $meta_key, $pending, $claimed_pending);
        if (false === $persisted && get_post_meta($attachment_id, $meta_key, true) !== $pending) {
            return new WP_Error('operation_persistence_failed', __('The media signing operation could not persist its fresh prepared plan.', 'encypher-provenance'));
        }
        return true;
    }

    private function owns_fresh_media_hash_attempt(
        int $attachment_id,
        string $operation_key,
        int $plan_attempt
    ): bool {
        $pending = get_post_meta($attachment_id, '_encypher_media_operation_pending', true);
        return is_array($pending)
            && hash_equals($operation_key, (string) ($pending['generation'] ?? ''))
            && $plan_attempt === ($pending['hash_plan_attempt'] ?? null)
            && ! array_key_exists('hash_prepared_state', $pending);
    }

    /**
     * Persist the bounded prepared phase before finalize. The previous pending
     * value is the CAS token, so a stale worker cannot replace newer state.
     *
     * @param array<string,mixed> $prepared_state
     * @return true|WP_Error
     */
    private function persist_media_prepared_state(
        int $attachment_id,
        string $operation_key,
        int $plan_attempt,
        array $prepared_state
    ) {
        $meta_key = '_encypher_media_operation_pending';
        $pending = get_post_meta($attachment_id, $meta_key, true);
        if (
            ! is_array($pending)
            || ! hash_equals($operation_key, (string) ($pending['generation'] ?? ''))
            || $plan_attempt !== ($pending['hash_plan_attempt'] ?? null)
        ) {
            return new WP_Error('operation_persistence_failed', __('The media signing operation changed before its prepared phase could be persisted.', 'encypher-provenance'));
        }
        $existing = $pending['hash_prepared_state'] ?? null;
        if (null !== $existing) {
            return is_array($existing) && $existing === $prepared_state
                ? true
                : new WP_Error('operation_persistence_failed', __('A newer prepared phase superseded this media signing worker.', 'encypher-provenance'));
        }
        $encoded = wp_json_encode($prepared_state);
        if (! is_string($encoded) || strlen($encoded) > 1_048_576) {
            return new WP_Error('operation_persistence_failed', __('The prepared media signing state exceeds its persistence limit.', 'encypher-provenance'));
        }
        $updated = $pending;
        $updated['hash_prepared_state'] = $prepared_state;
        $persisted = update_post_meta($attachment_id, $meta_key, $updated, $pending);
        if (false === $persisted && get_post_meta($attachment_id, $meta_key, true) !== $updated) {
            return new WP_Error('operation_persistence_failed', __('The prepared media signing phase could not be persisted.', 'encypher-provenance'));
        }
        return true;
    }

    private function complete_media_inventory_operation(int $attachment_id, string $operation_key): void
    {
        $meta_key = '_encypher_media_operation_pending';
        $pending = get_post_meta($attachment_id, $meta_key, true);
        if (
            is_array($pending)
            && hash_equals($operation_key, (string) ($pending['generation'] ?? ''))
        ) {
            delete_post_meta($attachment_id, $meta_key, $pending);
        }
    }

    // =========================================================================
    // Media signing via /sign/media
    // =========================================================================

    private const PREPARED_SIGNING_PROTOCOL = 'encypher-prepared-signing/1';

    private const BYTE_UPLOAD_MEDIA_TYPES = [
        'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/tiff',
        'image/avif', 'image/heic', 'image/heif',
        'video/mp4', 'video/quicktime',
        'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/mp4',
        'application/pdf',
    ];

    /**
     * Whether a MIME type is accepted by either local hash signing or the
     * legacy full-asset upload path.
     */
    public static function is_signable_media_mime(string $mime): bool
    {
        $canonical = HashMediaPlanner::canonical_mime($mime);
        return HashMediaPlanner::supports($canonical)
            || in_array($canonical, self::BYTE_UPLOAD_MEDIA_TYPES, true);
    }

    public function sign_featured_image(int $post_id): void
    {
        if ($this->is_autosave_or_revision($post_id)) {
            return;
        }
        $settings = get_option('encypher_provenance_settings', []);
        // The caller (save_post text-only path) owns the auto_sign_images gate;
        // when this method is invoked the featured image is meant to be signed.

        $thumbnail_id = (int) get_post_thumbnail_id($post_id);
        if ($thumbnail_id <= 0) {
            return;
        }
        if (! current_user_can('edit_post', $thumbnail_id)) {
            $this->debug_log(sprintf('Skipping featured image signing for post %d: current user cannot edit attachment %d', $post_id, $thumbnail_id));
            return;
        }

        $file_path = get_attached_file($thumbnail_id);
        if (!$file_path || !file_exists($file_path)) {
            $this->debug_log(sprintf('Featured image file not found for post %d (attachment %d)', $post_id, $thumbnail_id));
            return;
        }

        $mime_type = get_post_mime_type($thumbnail_id);
        if (! self::is_signable_media_mime((string) $mime_type)) {
            $this->debug_log(sprintf('Unsupported media mime type %s for post %d', $mime_type, $post_id));
            return;
        }

        $media_context = [
            'title' => function_exists('get_the_title') ? get_the_title($post_id) : '',
            'original_url' => wp_get_attachment_url($thumbnail_id) ?: '',
        ];
        $settings = $this->apply_media_rights_projection(
            $thumbnail_id,
            $post_id,
            is_array($settings) ? $settings : [],
            $media_context
        );
        $settings['install_id'] = $this->ensure_local_install_id($settings);
        $inventory_contract = $this->claim_media_inventory_contract(
            $thumbnail_id,
            $file_path,
            (string) $mime_type,
            $media_context,
            $settings
        );
        if (is_wp_error($inventory_contract)) {
            $this->debug_log(sprintf('Media signing refused for post %d: %s', $post_id, $inventory_contract->get_error_message()));
            return;
        }
        $media_context['asset_id'] = $inventory_contract['logical_asset_id'];
        $media_context['operation_key'] = $inventory_contract['operation_key'];
        $media_context['_operation_attachment_id'] = $thumbnail_id;
        $media_context['_hash_plan_attempt'] = $inventory_contract['hash_plan_attempt'];
        $media_context['_hash_prepared_state'] = $inventory_contract['hash_prepared_state'];
        $media_context['source'] = 'wordpress';
        $media_context['source_metadata'] = [
            'wordpress_install_id' => $settings['install_id'],
            'wordpress_post_id' => $thumbnail_id,
        ];

        $result = $this->post_sign_media($file_path, (string) $mime_type, $media_context, $settings);
        if (is_wp_error($result)) {
            $this->debug_log(sprintf('Media signing failed for post %d: %s', $post_id, $result->get_error_message()));
            return;
        }

        $record_id    = (string) ($result['record_id'] ?? '');
        $manifest_url = (string) ($result['manifest_url'] ?? '');
        $signed_bytes = (string) ($result['signed_bytes'] ?? '');
        $hash_committed = ! empty($result['hash_committed']);
        if ('' === $record_id || (! $hash_committed && '' === $signed_bytes)) {
            if ($hash_committed) {
                $this->release_attachment_commit_lock(
                    (int) $thumbnail_id,
                    (string) ($result['_attachment_commit_token'] ?? '')
                );
            }
            $this->debug_log(sprintf('Media signing returned incomplete output for post %d', $post_id));
            return;
        }
        $success_meta = [
            '_encypher_image_record_id' => sanitize_text_field($record_id),
            '_encypher_image_manifest_url' => esc_url_raw($manifest_url),
            '_encypher_provenance_status' => 'signed',
            '_encypher_signed_at' => current_time('mysql'),
            '_encypher_signed_hash' => sanitize_text_field((string) ($result['signed_hash'] ?? '')),
            '_encypher_c2pa_instance_id' => sanitize_text_field((string) ($result['c2pa_instance_id'] ?? '')),
        ];
        $commit = $hash_committed
            ? $this->commit_prepared_attachment(
                (int) $thumbnail_id,
                (string) ($result['signed_hash'] ?? ''),
                $success_meta + [
                    '_encypher_rights' => ! empty($result['rights']) && is_array($result['rights'])
                        ? wp_json_encode($result['rights'])
                        : null,
                ],
                (string) $inventory_contract['operation_key'],
                (string) ($result['_attachment_commit_token'] ?? '')
            )
            : $this->write_and_commit_signed_attachment(
                (int) $thumbnail_id,
                $signed_bytes,
                (string) ($result['original_bytes'] ?? ''),
                $success_meta,
                $result
            );
        if (! $commit['written']) {
            $write_error = $commit['error'];
            $message = is_wp_error($write_error) ? $write_error->get_error_message() : 'Unknown write failure.';
            $this->debug_log(sprintf('Media signing write failed for post %d: %s', $post_id, $message));
            return;
        }
        $this->complete_media_inventory_operation($thumbnail_id, (string) $inventory_contract['operation_key']);
        if (is_wp_error($commit['error'])) {
            $this->debug_log(sprintf('VIP media purge failed for post %d: %s', $post_id, $commit['error']->get_error_message()));
        }
        $this->debug_log(sprintf('Media signed for post %d: record_id=%s', $post_id, $record_id));
    }

    /**
     * Sign a single media file through POST /sign/media and return the signed
     * bytes with their record id and verification URL. Shared transport for
     * the featured-image and standalone attachment paths. Returns WP_Error
     * when the API did not produce an actual C2PA-protected artifact.
     *
     * @param array<string,mixed> $media_context title/original_url/rights.
     * @param array<string,mixed> $settings      Plugin settings.
     * @param callable|null       $heartbeat     Returns false when the signing lease is lost.
     * @return array{record_id:string,manifest_url:string,signed_bytes:string,original_bytes:string,signed_hash:string,c2pa_instance_id:string,rights:array<string,mixed>}|WP_Error
     */
    private function post_sign_media(
        string $file_path,
        string $mime_type,
        array $media_context,
        array $settings,
        ?callable $heartbeat = null
    )
    {
        $mime_type = HashMediaPlanner::canonical_mime($mime_type);
        if (! self::is_signable_media_mime($mime_type)) {
            return new WP_Error(
                'unsupported_media_type',
                sprintf(
                    /* translators: %s: media MIME type. */
                    __('The media type %s cannot be signed by Encypher.', 'encypher-provenance'),
                    $mime_type
                )
            );
        }
        $api_key = $settings['api_key'] ?? '';
        if (empty($api_key)) {
            return new WP_Error('encypher_no_api_key', 'No Encypher API key is configured.');
        }
        if (null !== $heartbeat && ! $heartbeat()) {
            return new WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
        }
        $api_base = rtrim($settings['api_base_url'] ?? 'https://api.encypher.com/api/v1', '/');
        $allow_full_asset_upload = ! array_key_exists('allow_full_asset_upload', $settings)
            || ! empty($settings['allow_full_asset_upload']);
        $hash_result = $this->post_sign_media_by_hash($file_path, $mime_type, $media_context, $settings, $heartbeat);
        if (is_wp_error($hash_result) && self::hash_error_allows_byte_fallback($hash_result, $allow_full_asset_upload)) {
            $hash_result = null;
        }
        if (null !== $hash_result) {
            return $hash_result;
        }
        if (! $allow_full_asset_upload) {
            return new WP_Error(
                'full_asset_upload_disabled',
                __('This media asset is not eligible for local hash signing, and full asset uploads are disabled.', 'encypher-provenance')
            );
        }


        $image_data = file_get_contents($file_path); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local read of a media file for signing, not a remote fetch.
        if ($image_data === false) {
            return new WP_Error('encypher_media_read_failed', 'Could not read the media file for signing.');
        }

        $media_payload = SignRequestBuilder::build_media_sign_payload($file_path, $media_context, $settings);

        $boundary = wp_generate_password(24, false);
        $body = "--{$boundary}\r\n";
        $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"" . basename($file_path) . "\"\r\n";
        $body .= "Content-Type: {$mime_type}\r\n\r\n";
        $body .= $image_data . "\r\n";

        foreach ($media_payload as $key => $value) {
            if ($key === 'file_path') {
                continue;
            }
            $body .= "--{$boundary}\r\n";
            $body .= "Content-Disposition: form-data; name=\"{$key}\"\r\n\r\n";
            $body .= (is_bool($value) ? ($value ? 'true' : 'false') : (is_array($value) ? wp_json_encode($value) : (string) $value)) . "\r\n";
        }
        $body .= "--{$boundary}--";

        $response = $this->guarded_remote_post(
            $api_base . '/sign/media',
            [
                // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin/editor rich media signing (large uploads); not an anonymous front-end request.
                'timeout' => 60,
                'headers' => [
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
                    'Idempotency-Key' => (string) ($media_context['operation_key'] ?? ''),
                ],
                'body' => $body,
            ]
        );
        if (null !== $heartbeat && ! $heartbeat()) {
            return new WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
        }

        if (is_wp_error($response)) {
            return $response;
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        if ($status !== 201 && $status !== 200) {
            $fallback = sprintf('Media signing API returned HTTP %d.', $status);
            $message = self::extract_api_error_detail($response, $fallback);
            if ($message !== $fallback) {
                $message = sprintf('%s (HTTP %d).', rtrim($message, ". \t\n\r\0\x0B"), $status);
            }
            return new WP_Error('encypher_media_http_' . $status, $message);
        }

        $data = json_decode(wp_remote_retrieve_body($response), true);
        if (!is_array($data)) {
            return new WP_Error('encypher_media_bad_response', 'Media signing API returned an unreadable response.');
        }

        $result = isset($data['data']) && is_array($data['data']) ? $data['data'] : $data;
        if (empty($result['c2pa_signed'])) {
            $reason = isset($result['passthrough_reason']) && is_string($result['passthrough_reason'])
                ? $result['passthrough_reason']
                : 'The signing service did not produce a C2PA-protected file.';
            return new WP_Error('encypher_media_not_signed', $reason);
        }
        $signed_hash = strtolower(trim((string) ($result['signed_hash'] ?? '')));
        $instance_id = trim((string) ($result['c2pa_instance_id'] ?? ''));
        $asset_type = strtolower((string) (explode('/', $mime_type, 2)[0] ?? ''));
        $signed_bytes = self::validate_signed_media_evidence(
            $result['signed_file_b64'] ?? '',
            $signed_hash,
            $instance_id,
            $asset_type,
            true === ($result['c2pa_signed'] ?? false)
        );
        if (is_wp_error($signed_bytes)) {
            return $signed_bytes;
        }
        $verified = $this->verify_signed_media_before_write(
            $signed_bytes,
            $mime_type,
            basename($file_path),
            $instance_id
        );
        if (is_wp_error($verified)) {
            return $verified;
        }
        if (null !== $heartbeat && ! $heartbeat()) {
            return new WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
        }
        return [
            'record_id'    => (string) ($result['record_id'] ?? $result['document_id'] ?? ''),
            'manifest_url' => (string) ($result['manifest_url'] ?? $result['verification_url'] ?? ''),
            'signed_bytes' => $signed_bytes,
            'original_bytes' => $image_data,
            'signed_hash'      => $signed_hash,
            'c2pa_instance_id' => $instance_id,
            'rights'           => isset($result['rights']) && is_array($result['rights']) ? $result['rights'] : [],
            'rights_evidence_id' => $result['rights_evidence_id'] ?? null,
            'rights_resolution_url' => $result['rights_resolution_url'] ?? null,
        ];
    }

    /**
     * Use prepared hash signing for large local media. Null means the server
     * did not advertise the capability, so the caller may use byte mode.
     *
     * @return array<string,mixed>|\WP_Error|null
     */
    private function post_sign_media_by_hash(
        string $file_path,
        string $mime_type,
        array $media_context,
        array $settings,
        ?callable $heartbeat = null
    ) {
        $allow_full_asset_upload = ! array_key_exists('allow_full_asset_upload', $settings)
            || ! empty($settings['allow_full_asset_upload']);
        $local_hashing = ! empty($settings['local_hashing']);
        if (! $local_hashing || ! Capabilities::allows(Capabilities::LOCAL_HASH_SIGNING)) {
            return null;
        }
        if (! HashMediaWriter::is_local_path($file_path)) {
            return new \WP_Error(
                'encypher_hash_non_local_path',
                'Prepared hash signing requires a local media file.',
                [
                    'status' => 422,
                    'detail' => [
                        'code' => 'HASH_MODE_UNSUPPORTED',
                        'reason' => 'NON_LOCAL_PATH',
                    ],
                ]
            );
        }

        $file_size = filesize($file_path);
        $category = strtolower((string) (explode('/', $mime_type, 2)[0] ?? ''));
        $threshold = 'image' === $category ? 10 * 1024 * 1024 : 25 * 1024 * 1024;
        if (
            ! is_int($file_size)
            || ($allow_full_asset_upload && $file_size <= $threshold)
            || ! HashMediaPlanner::supports($mime_type)
        ) {
            return null;
        }
        $byte_required = self::hash_byte_required_error($mime_type, $media_context, $settings);
        if (is_wp_error($byte_required)) {
            return $byte_required;
        }

        $api_key = (string) ($settings['api_key'] ?? '');
        $api_base = rtrim((string) ($settings['api_base_url'] ?? 'https://api.encypher.com/api/v1'), '/');
        $headers = [
            'Authorization' => 'Bearer ' . $api_key,
            'Content-Type' => 'application/json',
        ];
        $headers['Idempotency-Key'] = (string) ($media_context['operation_key'] ?? '');
        if (null !== $heartbeat && ! $heartbeat()) {
            return new \WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
        }
        $capabilities = $this->hash_signing_capabilities($api_base, $api_key, $headers);
        if (null !== $heartbeat && ! $heartbeat()) {
            return new \WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
        }
        if (
            null === $capabilities
            || ! in_array($mime_type, (array) ($capabilities['formats'] ?? []), true)
        ) {
            return null;
        }

        $attachment_id = (int) ($media_context['_operation_attachment_id'] ?? 0);
        if ($attachment_id <= 0) {
            return new \WP_Error('operation_persistence_failed', 'Prepared signing requires a valid durable media operation.');
        }
        $commit_token = $this->acquire_attachment_commit_lock($attachment_id);
        if (null === $commit_token) {
            return new \WP_Error('signing_superseded', 'A newer attachment signing commit is in progress.');
        }
        $worker_heartbeat = $heartbeat;
        $next_commit_renewal = 0;
        $heartbeat = function () use (
            $worker_heartbeat,
            $attachment_id,
            $commit_token,
            &$next_commit_renewal
        ): bool {
            return $this->heartbeat_attachment_commit(
                $attachment_id,
                $commit_token,
                $worker_heartbeat,
                $next_commit_renewal
            );
        };
        try {
        $hash_result = HashMediaWriter::with_locked_source(
            $file_path,
            function ($source, array $source_stat) use (
                $api_base,
                $headers,
                $file_path,
                $file_size,
                $mime_type,
                $media_context,
                $settings,
                $heartbeat
            ) {
                if ((int) ($source_stat['size'] ?? -1) !== $file_size) {
                    return new \WP_Error('source_changed', 'The media file changed before hash signing started.');
                }
                if (null !== $heartbeat && ! $heartbeat()) {
                    return new \WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
                }
                $structure = HashMediaPlanner::plan($source, $file_size, $mime_type);
                if (is_wp_error($structure)) {
                    return $structure;
                }
                $media_payload = SignRequestBuilder::build_media_sign_payload($file_path, $media_context, $settings);
                $binding_plan = self::prepared_binding_plan($source, $file_size, $mime_type);
                if (is_wp_error($binding_plan)) {
                    return $binding_plan;
                }
                $prepare_payload = self::prepared_request_payload(
                    $file_path,
                    $mime_type,
                    $file_size,
                    $structure,
                    $binding_plan,
                    $media_payload
                );
                if (is_wp_error($prepare_payload)) {
                    return $prepare_payload;
                }

                $saved_prepared_state = $media_context['_hash_prepared_state'] ?? null;
                $operation_key = (string) ($media_context['operation_key'] ?? '');
                $attachment_id = (int) ($media_context['_operation_attachment_id'] ?? 0);
                return $this->execute_hash_plan_with_refresh(function (string $attempt_key, int $attempt_index) use (
                    $api_base,
                    $headers,
                    $prepare_payload,
                    $binding_plan,
                    $source,
                    $source_stat,
                    $file_path,
                    $mime_type,
                    $media_payload,
                    $heartbeat,
                    $saved_prepared_state,
                    $operation_key,
                    $attachment_id
                ) {
                    $attempt_headers = $headers;
                    $attempt_headers['Idempotency-Key'] = $attempt_key;
                    $replayed = is_array($saved_prepared_state)
                        && $attempt_index === ($saved_prepared_state['attempt'] ?? null);
                    if (
                        is_array($saved_prepared_state)
                        && ! $replayed
                        && ! $this->owns_fresh_media_hash_attempt($attachment_id, $operation_key, $attempt_index)
                    ) {
                        return new \WP_Error('operation_persistence_failed', 'The persisted prepared media signing attempt does not match its operation lineage.');
                    }
                    if ($replayed) {
                        $prepared_state = self::normalize_durable_prepared_state(
                            $saved_prepared_state,
                            $mime_type,
                            $binding_plan,
                            $attempt_index
                        );
                        if (is_wp_error($prepared_state)) {
                            return $prepared_state;
                        }
                        $prepare = $prepared_state['plan'];
                        $plan_id = $prepared_state['plan_id'];
                        $instance_id = $prepared_state['instance_id'];
                        $saved_binding = $prepared_state['binding'];
                    } else {
                        $prepare = $this->post_hash_json(
                            $api_base . '/sign/media/prepare',
                            $attempt_headers,
                            $prepare_payload,
                            90,
                            $heartbeat
                        );
                        if (null !== $heartbeat && ! $heartbeat()) {
                            return new \WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
                        }
                        if (is_wp_error($prepare)) {
                            return $prepare;
                        }
                        $plan_id = (string) ($prepare['plan_id'] ?? '');
                        $instance_id = (string) ($prepare['instance_id'] ?? '');
                        $prepare = self::normalize_prepared_hash_plan($prepare, $mime_type, $binding_plan);
                        if (is_wp_error($prepare) || '' === $plan_id || '' === $instance_id) {
                            return is_wp_error($prepare)
                                ? $prepare
                                : new \WP_Error('hash_prepare_invalid', 'The hash-signing service returned an incomplete plan.');
                        }
                        $prepare = self::durable_prepared_plan($prepare);
                        $saved_binding = null;
                    }

                    $finalized = HashMediaWriter::apply_and_commit(
                        $source,
                        $source_stat,
                        $file_path,
                        $prepare,
                        function (string $digest) use (
                            $api_base,
                            $attempt_headers,
                            $plan_id,
                            $instance_id,
                            $prepare,
                            $heartbeat,
                            $replayed,
                            $saved_binding,
                            $attachment_id,
                            $operation_key,
                            $attempt_index,
                            $mime_type,
                            $binding_plan
                        ) {
                            if (null !== $heartbeat && ! $heartbeat()) {
                                return new \WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
                            }
                            $binding = self::prepared_binding_from_digest($prepare['binding_plan'], $digest);
                            if ($replayed) {
                                if ($saved_binding !== $binding) {
                                    return new \WP_Error('operation_persistence_failed', 'The persisted prepared binding no longer matches the local media source.');
                                }
                            } else {
                                $prepared_state = self::normalize_durable_prepared_state(
                                    [
                                        'attempt' => $attempt_index,
                                        'plan_id' => $plan_id,
                                        'instance_id' => $instance_id,
                                        'plan' => $prepare,
                                        'binding' => $binding,
                                    ],
                                    $mime_type,
                                    $binding_plan,
                                    $attempt_index
                                );
                                if (is_wp_error($prepared_state)) {
                                    return new \WP_Error('hash_prepare_invalid', 'The hash-signing service returned an invalid durable plan identity.');
                                }
                                $persisted = $this->persist_media_prepared_state(
                                    $attachment_id,
                                    $operation_key,
                                    $attempt_index,
                                    $prepared_state
                                );
                                if (is_wp_error($persisted)) {
                                    return $persisted;
                                }
                            }
                            $finalize = $this->post_hash_json(
                                $api_base . '/sign/media/finalize',
                                $attempt_headers,
                                [
                                    'protocol' => self::PREPARED_SIGNING_PROTOCOL,
                                    'plan_id' => $plan_id,
                                    'binding' => $binding,
                                ],
                                120,
                                $heartbeat
                            );
                            if (null !== $heartbeat && ! $heartbeat()) {
                                return new \WP_Error('signing_lease_lost', 'The media signing lease is no longer active.');
                            }
                            if (is_wp_error($finalize)) {
                                return $finalize;
                            }
                            if (
                                self::PREPARED_SIGNING_PROTOCOL !== ($finalize['protocol'] ?? null)
                                || $plan_id !== (string) ($finalize['plan_id'] ?? '')
                                || ($finalize['binding'] ?? null) !== $binding
                                || (int) ($finalize['expected_final_size'] ?? -1) !== (int) $prepare['expected_final_size']
                            ) {
                                return new \WP_Error('hash_finalize_mismatch', 'The finalized manifest does not match the prepared signing plan.');
                            }
                            return self::normalize_prepared_finalize_identity($finalize);
                        },
                        $heartbeat
                    );
                    if (is_wp_error($finalized)) {
                        return $finalized;
                    }
                    return [
                        'hash_committed' => true,
                        'record_id' => (string) ($finalized['document_id'] ?? ''),
                        'manifest_url' => (string) ($finalized['verification_url'] ?? ''),
                        'signed_hash' => 'sha256:' . (string) ($finalized['signed_file_sha256'] ?? ''),
                        'c2pa_instance_id' => $instance_id,
                        'rights' => isset($media_payload['rights']) && is_array($media_payload['rights'])
                            ? $media_payload['rights']
                            : [],
                    ];
                }, $operation_key, $attachment_id, (int) ($media_context['_hash_plan_attempt'] ?? 0));
            },
            $heartbeat
        );
        } catch (\Throwable $error) {
            $this->release_attachment_commit_lock($attachment_id, $commit_token);
            throw $error;
        }
        if (is_wp_error($hash_result) || ! is_array($hash_result)) {
            if (
                is_wp_error($hash_result)
                && 'signing_lease_lost' === $hash_result->get_error_code()
                && ! $this->owns_attachment_commit_lock($attachment_id, $commit_token)
            ) {
                $hash_result = new \WP_Error('signing_superseded', 'A newer attachment signing commit superseded this worker.');
            }
            $this->release_attachment_commit_lock($attachment_id, $commit_token);
            return $hash_result;
        }
        $hash_result['_attachment_commit_token'] = $commit_token;
        return $hash_result;
    }

    /**
     * Retry one stale prepared plan with a fresh prepare. The attempt callback
     * owns prepare and finalize so a retry cannot reuse state from the rejected
     * plan.
     *
     * @return array<string,mixed>|\WP_Error
     */
    private function execute_hash_plan_with_refresh(
        callable $attempt,
        string $operation_key,
        int $attachment_id,
        int $plan_attempt
    ) {
        if ('' === $operation_key || $attachment_id <= 0 || $plan_attempt < 0 || $plan_attempt > 1) {
            return new \WP_Error('operation_persistence_failed', 'Prepared signing requires a valid durable media operation.');
        }
        for (; $plan_attempt < 2; ++$plan_attempt) {
            $attempt_key = 'hash-plan-' . hash(
                'sha256',
                self::PREPARED_SIGNING_PROTOCOL . "\0" . $operation_key . "\0" . (string) $plan_attempt
            );
            $result = $attempt($attempt_key, $plan_attempt);
            if (
                0 === $plan_attempt
                && is_wp_error($result)
                && self::hash_error_requires_fresh_plan($result)
            ) {
                $advanced = $this->advance_media_hash_plan_attempt($attachment_id, $operation_key, $plan_attempt);
                if (is_wp_error($advanced)) {
                    return $advanced;
                }
                continue;
            }
            return $result;
        }

        return new \WP_Error('hash_plan_retry_exhausted', 'Hash signing exhausted its fresh-plan retry.');
    }

    /** @return array<string,mixed>|\WP_Error */
    private static function prepared_request_payload(
        string $file_path,
        string $mime_type,
        int $file_size,
        array $structure,
        array $binding_plan,
        array $media_payload
    ) {
        $payload = [
            'protocol' => self::PREPARED_SIGNING_PROTOCOL,
            'mime' => $mime_type,
            'total_size' => $file_size,
            'structure' => $structure,
            'binding' => $binding_plan,
            'title' => (string) ($media_payload['title'] ?? basename($file_path)),
            'action' => (string) ($media_payload['action'] ?? 'c2pa.created'),
            'ai_output' => ! empty($media_payload['ai_output']),
        ];
        if (! empty($media_payload['asset_id'])) {
            $payload['asset_id'] = (string) $media_payload['asset_id'];
        }
        foreach (['rights', 'rights_template_id', 'license_type', 'license_url', 'digital_source_type'] as $field) {
            if (array_key_exists($field, $media_payload)) {
                $payload[$field] = $media_payload[$field];
            }
        }
        if (! empty($media_payload['preserve_source_rights'])) {
            $payload['preserve_source_rights'] = true;
        }
        if (! empty($media_payload['use_rights_profile'])) {
            $payload['use_rights_profile'] = true;
        }
        $ingredients = ! empty($media_payload['ingredients']) && is_array($media_payload['ingredients'])
            ? $media_payload['ingredients']
            : [];
        $has_parent = false;
        foreach ($ingredients as $ingredient) {
            if (is_array($ingredient) && 'parentOf' === ($ingredient['relationship'] ?? null)) {
                $has_parent = true;
                break;
            }
        }
        if ('c2pa.created' !== $payload['action'] && ! $has_parent) {
            array_unshift($ingredients, [
                'path' => $file_path,
                'mime' => $mime_type,
                'relationship' => 'parentOf',
                'title' => $payload['title'],
            ]);
        }
        if ('c2pa.created' !== $payload['action'] && ! $has_parent) {
            $payload['ingredient'] = true;
        }
        if (! empty($ingredients)) {
            $descriptors = self::derive_prepared_ingredient_descriptors($ingredients);
            if (is_wp_error($descriptors)) {
                return $descriptors;
            }
            if (! empty($descriptors)) {
                $payload['ingredients'] = $descriptors;
            }
        }
        return $payload;
    }

    /**
     * Derive geometry-and-digest descriptors for local prepared-signing
     * ingredients. Each ingredient is planned through HashMediaPlanner so only
     * its bounded sparse structure travels; ingredient media bytes never enter
     * the prepared request.
     *
     * @param array<int,mixed> $ingredients Local ingredient specs, each with
     *        keys: path, mime, and optional relationship and title.
     * @return array<int,array<string,mixed>>|\WP_Error
     */
    private static function derive_prepared_ingredient_descriptors(array $ingredients)
    {
        $descriptors = [];
        foreach ($ingredients as $ingredient) {
            if (! is_array($ingredient)) {
                return new \WP_Error('hash_ingredient_invalid', 'A prepared ingredient descriptor is malformed.');
            }
            $mime = HashMediaPlanner::canonical_mime((string) ($ingredient['mime'] ?? ''));
            if ('' === $mime) {
                return new \WP_Error('hash_ingredient_invalid', 'A prepared ingredient requires an explicit MIME type.');
            }
            if (! HashMediaPlanner::supports($mime)) {
                return new \WP_Error('hash_ingredient_unsupported', sprintf('Prepared signing does not support ingredient MIME %s.', $mime));
            }
            $path = (string) ($ingredient['path'] ?? '');
            if ('' === $path || ! HashMediaWriter::is_local_path($path)) {
                return new \WP_Error('hash_ingredient_invalid', 'A prepared ingredient must be a local file.');
            }
            $descriptor = self::derive_prepared_ingredient_descriptor($path, $mime, $ingredient);
            if (is_wp_error($descriptor)) {
                return $descriptor;
            }
            $descriptors[] = $descriptor;
        }
        return $descriptors;
    }

    /** @return array<string,mixed>|\WP_Error */
    private static function derive_prepared_ingredient_descriptor(string $path, string $mime, array $ingredient)
    {
        // Prepared mode carries no arbitrary asset cap. The only structural
        // ceiling is JSON safety: total_size + carrier must stay within 2**53-1
        // so every wire integer round-trips. Carrier headroom is 512 KiB.
        $max_ingredient_bytes = (1 << 53) - 1 - (512 * 1024);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- prepared signing reads local bytes without egress.
        $handle = fopen($path, 'rb');
        if (false === $handle) {
            return new \WP_Error('hash_ingredient_read_failed', 'A prepared ingredient could not be opened.');
        }
        try {
            $stat = fstat($handle);
            $size = is_array($stat) ? (int) ($stat['size'] ?? -1) : -1;
            $mode = is_array($stat) ? (int) ($stat['mode'] ?? 0) : 0;
            if (($mode & 0170000) !== 0100000 || $size <= 0 || $size > $max_ingredient_bytes) {
                return new \WP_Error('hash_ingredient_invalid', 'A prepared ingredient must be a regular local file within protocol size limits.');
            }
            if (0 !== fseek($handle, 0, SEEK_SET)) {
                return new \WP_Error('hash_ingredient_read_failed', 'A prepared ingredient could not be read.');
            }
            $digest_context = hash_init('sha256');
            while (! feof($handle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- same open handle is required for the anti-swap digest.
                $chunk = fread($handle, 1 << 20);
                if (false === $chunk) {
                    return new \WP_Error('hash_ingredient_read_failed', 'A prepared ingredient could not be read.');
                }
                hash_update($digest_context, $chunk);
            }
            $source_sha256 = hash_final($digest_context);
            $structure = HashMediaPlanner::plan($handle, $size, $mime);
            if (is_wp_error($structure)) {
                return $structure;
            }
            // Re-verify the source did not change between the digest and the
            // structure plan, matching the Rust/TS/Go SDK helpers: the shipped
            // digest must describe exactly the bytes the shipped sparse
            // structure was planned from. The server never sees ingredient
            // bytes and cannot detect an in-flight swap, so a concurrent
            // in-place edit would otherwise ship a digest and a structure that
            // describe different snapshots. Re-stat for identity, then re-hash
            // and compare in constant time.
            $stat_after = fstat($handle);
            $ino_before = is_array($stat) ? (int) ($stat['ino'] ?? 0) : 0;
            $ino_after = is_array($stat_after) ? (int) ($stat_after['ino'] ?? 0) : -1;
            $dev_before = is_array($stat) ? (int) ($stat['dev'] ?? 0) : 0;
            $dev_after = is_array($stat_after) ? (int) ($stat_after['dev'] ?? 0) : -1;
            $mtime_before = is_array($stat) ? (int) ($stat['mtime'] ?? 0) : 0;
            $mtime_after = is_array($stat_after) ? (int) ($stat_after['mtime'] ?? 0) : -1;
            $size_after = is_array($stat_after) ? (int) ($stat_after['size'] ?? -1) : -1;
            if ($size_after !== $size
                || $ino_after !== $ino_before
                || $dev_after !== $dev_before
                || $mtime_after !== $mtime_before) {
                return new \WP_Error('hash_ingredient_invalid', 'A prepared ingredient changed on disk during preparation.');
            }
            if (0 !== fseek($handle, 0, SEEK_SET)) {
                return new \WP_Error('hash_ingredient_read_failed', 'A prepared ingredient could not be read.');
            }
            $reverify_context = hash_init('sha256');
            while (! feof($handle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- re-reading the same handle proves the planned bytes did not change.
                $chunk = fread($handle, 1 << 20);
                if (false === $chunk) {
                    return new \WP_Error('hash_ingredient_read_failed', 'A prepared ingredient could not be read.');
                }
                hash_update($reverify_context, $chunk);
            }
            if (! hash_equals($source_sha256, hash_final($reverify_context))) {
                return new \WP_Error('hash_ingredient_invalid', 'A prepared ingredient changed on disk during preparation.');
            }
            $relationship = (string) ($ingredient['relationship'] ?? '');
            if ('' === $relationship) {
                $relationship = 'componentOf';
            }
            $title = array_key_exists('title', $ingredient) && null !== $ingredient['title']
                ? (string) $ingredient['title']
                : basename($path);
            return [
                'mime' => $mime,
                'relationship' => $relationship,
                'total_size' => $size,
                'source_sha256' => $source_sha256,
                'title' => $title,
                'structure' => $structure,
            ];
        } finally {
            fclose($handle);
        }
    }

    /** @return array<string,mixed>|\WP_Error */
    private static function prepared_binding_plan($source, int $size, string $mime_type)
    {
        unset($source, $size);
        return [
            'type' => 'c2pa.hash.bmff.v3' === HashMediaPlanner::binding_algorithm_for_mime($mime_type)
                ? 'bmff_hash'
                : 'data_hash',
        ];
    }

    /** @return array<string,mixed>|\WP_Error */
    private static function normalize_prepared_hash_plan(array $response, string $mime_type, array $binding_plan)
    {
        $expected_manifest_size = $response['expected_manifest_size'] ?? null;
        if (
            self::PREPARED_SIGNING_PROTOCOL !== ($response['protocol'] ?? null)
            || $mime_type !== ($response['mime'] ?? null)
            || ! is_int($expected_manifest_size)
            || $expected_manifest_size <= 0
            || $expected_manifest_size > 262_144
            || ! is_int($response['expected_final_size'] ?? null)
            || ! is_array($response['binding_recipe'] ?? null)
            || ! is_array($response['edits'] ?? null)
        ) {
            return new \WP_Error('hash_prepare_invalid', 'The hash-signing service returned an incomplete or mismatched plan.');
        }
        $recipe = $response['binding_recipe'];
        $type = (string) ($binding_plan['type'] ?? '');
        if (
            $type !== ($recipe['type'] ?? null)
            || 'sha256' !== ($recipe['algorithm'] ?? null)
            || ! in_array($type, ['data_hash', 'bmff_hash'], true)
        ) {
            return new \WP_Error('hash_prepare_invalid', 'The prepared binding recipe does not match the requested binding.');
        }

        $carrier_span = $response['carrier_span'] ?? null;
        $carrier_output_span = $response['carrier_output_span'] ?? null;
        if (
            ! self::valid_prepared_byte_range($carrier_span)
            || ! self::valid_prepared_byte_range($carrier_output_span)
            || 0 !== $carrier_span['offset']
            || $carrier_span['length'] !== $carrier_output_span['length']
        ) {
            return new \WP_Error('hash_prepare_invalid', 'The prepared carrier geometry is invalid.');
        }
        if ($carrier_span['length'] > 524_288) {
            return new \WP_Error('hash_prepare_invalid', 'The prepared carrier exceeds the 512 KiB local limit.');
        }

        $carrier_edit = null;
        foreach ($response['edits'] as $edit) {
            if (
                ! is_array($edit)
                || 3 !== count($edit)
                || ! in_array($edit['op'] ?? null, ['insert_carrier', 'delete', 'patch'], true)
                || ! is_int($edit['offset'] ?? null)
                || $edit['offset'] < 0
            ) {
                return new \WP_Error('hash_prepare_invalid', 'The prepared edit list is malformed.');
            }
            if ('patch' === $edit['op']) {
                if (! is_string($edit['b64'] ?? null)) {
                    return new \WP_Error('hash_prepare_invalid', 'The prepared patch edit is invalid.');
                }
            } elseif (! is_int($edit['length'] ?? null) || $edit['length'] <= 0) {
                return new \WP_Error('hash_prepare_invalid', 'The prepared edit length is invalid.');
            }
            if ('insert_carrier' === $edit['op']) {
                if (null !== $carrier_edit) {
                    return new \WP_Error('hash_prepare_invalid', 'The prepared plan has multiple manifest carriers.');
                }
                $carrier_edit = $edit;
            }
        }
        if (null === $carrier_edit || $carrier_edit['length'] !== $carrier_span['length']) {
            return new \WP_Error('hash_prepare_invalid', 'The prepared plan has no matching manifest carrier.');
        }

        if ('data_hash' === $type) {
            if (($recipe['exclusions'] ?? null) !== [$carrier_output_span]) {
                return new \WP_Error('hash_prepare_invalid', 'The data-hash exclusion does not match the carrier output span.');
            }
            $exclusions = [[$carrier_output_span['offset'], $carrier_output_span['length']]];
            $algorithm = 'c2pa.hash.data';
        } else {
            if (($recipe['exclusions'] ?? null) !== self::BMFF_EXCLUSIONS) {
                return new \WP_Error('hash_prepare_invalid', 'The BMFF recipe does not use canonical exclusions.');
            }
            $exclusions = self::BMFF_EXCLUSIONS;
            $algorithm = 'c2pa.hash.bmff.v3';
        }
        $response['binding_plan'] = $binding_plan;
        $response['binding_algorithm'] = $algorithm;
        $response['exclusions'] = $exclusions;
        return $response;
    }

    /** @return array<string,mixed> */
    private static function durable_prepared_plan(array $plan): array
    {
        return [
            'protocol' => $plan['protocol'],
            'mime' => $plan['mime'],
            'expected_manifest_size' => $plan['expected_manifest_size'],
            'expected_final_size' => $plan['expected_final_size'],
            'binding_recipe' => $plan['binding_recipe'],
            'edits' => $plan['edits'],
            'carrier_span' => $plan['carrier_span'],
            'carrier_output_span' => $plan['carrier_output_span'],
            'binding_plan' => $plan['binding_plan'],
            'binding_algorithm' => $plan['binding_algorithm'],
            'exclusions' => $plan['exclusions'],
        ];
    }

    /**
     * @param array<string,mixed> $state
     * @return array<string,mixed>|WP_Error
     */
    private static function normalize_durable_prepared_state(
        array $state,
        string $mime_type,
        array $binding_plan,
        int $plan_attempt
    ) {
        $encoded = wp_json_encode($state);
        $plan_id = $state['plan_id'] ?? null;
        $instance_id = $state['instance_id'] ?? null;
        $binding = $state['binding'] ?? null;
        if (
            5 !== count($state)
            || $plan_attempt !== ($state['attempt'] ?? null)
            || ! is_string($plan_id)
            || '' === trim($plan_id)
            || strlen($plan_id) > 128
            || ! is_string($instance_id)
            || '' === trim($instance_id)
            || strlen($instance_id) > 256
            || ! is_array($state['plan'] ?? null)
            || ! is_array($binding)
            || 2 !== count($binding)
            || ($binding['type'] ?? null) !== ($binding_plan['type'] ?? null)
            || ! is_string($binding['digest'] ?? null)
            || 1 !== preg_match('/\A[0-9a-f]{64}\z/', $binding['digest'])
            || ! is_string($encoded)
            || strlen($encoded) > 1_048_576
        ) {
            return new \WP_Error('operation_persistence_failed', 'The persisted prepared media signing state is invalid.');
        }
        $plan = self::normalize_prepared_hash_plan($state['plan'], $mime_type, $binding_plan);
        if (is_wp_error($plan)) {
            return new \WP_Error('operation_persistence_failed', 'The persisted prepared media signing plan is invalid.');
        }
        $state['plan'] = self::durable_prepared_plan($plan);
        return $state;
    }

    /** @return array<string,mixed>|WP_Error */
    private static function normalize_prepared_finalize_identity(array $finalize)
    {
        $document_id = $finalize['document_id'] ?? $finalize['record_id'] ?? null;
        if (! is_string($document_id)) {
            return new \WP_Error('hash_finalize_invalid', 'The finalized manifest has no document identity.');
        }
        $document_id = sanitize_text_field(trim($document_id));
        if ('' === $document_id || strlen($document_id) > 255) {
            return new \WP_Error('hash_finalize_invalid', 'The finalized manifest has no valid document identity.');
        }
        if (
            array_key_exists('verification_url', $finalize)
            && ! is_string($finalize['verification_url'])
        ) {
            return new \WP_Error('hash_finalize_invalid', 'The finalized manifest has an invalid verification URL.');
        }
        $finalize['document_id'] = $document_id;
        return $finalize;
    }

    private static function valid_prepared_byte_range($range): bool
    {
        return is_array($range)
            && 2 === count($range)
            && is_int($range['offset'] ?? null)
            && is_int($range['length'] ?? null)
            && $range['offset'] >= 0
            && $range['length'] > 0;
    }

    /** @return array<string,mixed> */
    private static function prepared_binding_from_digest(array $plan, string $digest): array
    {
        return [
            'type' => (string) $plan['type'],
            'digest' => $digest,
        ];
    }

    private static function hash_error_requires_fresh_plan(\WP_Error $error): bool
    {
        $data = $error->get_error_data();
        if (! is_array($data)) {
            return false;
        }
        $detail_code = self::hash_error_detail_code($error);
        return in_array(
            $detail_code,
            [
                'PLAN_NOT_FOUND',
                'PLAN_EXPIRED',
                'PLAN_ALREADY_FINALIZED',
                'PLAN_INVALID_STATE',
                'PLAN_PAYLOAD_PURGED',
                'PLAN_LEASE_LOST',
                'RIGHTS_CHANGED',
                'SIGNER_ROTATED',
            ],
            true
        );
    }

    private static function hash_byte_required_error(string $mime_type, array $media_context, array $settings): ?\WP_Error
    {
        if (str_starts_with($mime_type, 'image/') && ! empty($settings['enable_image_watermark'])) {
            return new \WP_Error(
                'encypher_hash_requires_bytes',
                'Image watermarking requires byte-mode signing.',
                ['status' => 422, 'detail' => ['code' => 'WATERMARK_REQUIRES_BYTES']]
            );
        }
        return null;
    }

    private static function hash_error_allows_byte_fallback(\WP_Error $error, bool $allow_full_asset_upload): bool
    {
        return $allow_full_asset_upload
            && in_array(
                self::hash_error_detail_code($error),
                [
                    'HASH_MODE_UNSUPPORTED',
                    'FEATURE_NOT_AVAILABLE',
                    'WATERMARK_REQUIRES_BYTES',
                ],
                true
            );
    }

    private static function hash_error_detail_code(\WP_Error $error): string
    {
        $data = $error->get_error_data();
        return is_array($data)
            && isset($data['detail'])
            && is_array($data['detail'])
            && isset($data['detail']['code'])
            && is_string($data['detail']['code'])
                ? $data['detail']['code']
                : '';
    }

    private static function wait_before_hash_retry(int $failed_attempt): void
    {
        $delay_us = (int) apply_filters(
            'encypher_hash_retry_delay_us',
            100000 * (2 ** $failed_attempt),
            $failed_attempt
        );
        if ($delay_us > 0) {
            usleep($delay_us);
        }
    }

    /** @return array<string,mixed>|\WP_Error */
    private function post_hash_json(
        string $url,
        array $headers,
        array $payload,
        int $timeout,
        ?callable $heartbeat = null
    ) {
        $body = wp_json_encode($payload);
        if (! is_string($body)) {
            return new \WP_Error('hash_request_invalid', 'Could not encode the hash-signing request.');
        }

        for ($attempt = 0; $attempt < 3; ++$attempt) {
            $response = $this->hash_remote_post(
                $url,
                [
                    'timeout' => $timeout,
                    'headers' => $headers,
                    'body' => $body,
                    'limit_response_size' => self::HASH_RESPONSE_LIMIT_BYTES,
                ],
                $heartbeat
            );
            if (is_wp_error($response)) {
                if ($attempt < 2) {
                    self::wait_before_hash_retry($attempt);
                    continue;
                }
                return $response;
            }

            $status = (int) wp_remote_retrieve_response_code($response);
            $decoded = json_decode(wp_remote_retrieve_body($response), true);
            if ($status < 200 || $status >= 300) {
                $fallback = sprintf('Hash-signing API returned HTTP %d.', $status);
                $detail = is_array($decoded)
                    && isset($decoded['detail'])
                    && is_array($decoded['detail'])
                        ? $decoded['detail']
                        : null;
                $error_data = ['status' => $status];
                if (null !== $detail) {
                    $error_data['detail'] = $detail;
                }
                $detail_code = is_array($detail) && is_string($detail['code'] ?? null)
                    ? $detail['code']
                    : '';
                $retry_same_call = 429 === $status
                    || 'PLAN_PROCESSING' === $detail_code
                    || ('' === $detail_code && in_array($status, [502, 503, 504], true));
                if ($retry_same_call && $attempt < 2) {
                    self::wait_before_hash_retry($attempt);
                    continue;
                }
                return new \WP_Error(
                    'encypher_hash_http_' . $status,
                    self::extract_api_error_detail($response, $fallback),
                    $error_data
                );
            }

            $payload_data = self::hash_api_payload($response);
            return is_array($payload_data)
                ? $payload_data
                : new \WP_Error('hash_response_invalid', 'The hash-signing API returned an unreadable response.');
        }

        return new \WP_Error('hash_retry_exhausted', 'Hash signing exhausted its request retries.');
    }

    /**
     * Dispatch one prepared-signing POST, joining the active wave when this
     * asset is running inside one and falling back to the guarded serial
     * transport when it is not.
     *
     * @param array<string,mixed> $args
     * @return array<string,mixed>|\WP_Error
     */
    private function hash_remote_post(string $url, array $args, ?callable $heartbeat = null)
    {
        $batched = $this->request_through_active_wave('POST', $url, $args, $heartbeat);
        return null === $batched ? $this->guarded_remote_post($url, $args) : $batched;
    }

    /**
     * Hand one request to the active batched wave.
     *
     * Returns null when the call must run serially: no wave is collecting, the
     * PHP build has no Fibers, or this call is not on a wave fiber's stack.
     *
     * @param array<string,mixed> $args
     * @return array<string,mixed>|\WP_Error|null
     */
    private function request_through_active_wave(
        string $method,
        string $url,
        array $args,
        ?callable $heartbeat
    ) {
        if (! $this->hash_wave_active || ! class_exists('Fiber') || null === \Fiber::getCurrent()) {
            return null;
        }
        $response = \Fiber::suspend([
            'method' => $method,
            'url' => $url,
            'args' => $args,
            'heartbeat' => $heartbeat,
        ]);
        if (is_array($response) || is_wp_error($response)) {
            return $response;
        }
        return new \WP_Error(
            'hash_wave_dispatch_failed',
            'The batched hash-signing request produced no response.'
        );
    }

    /**
     * The organization's prepared-signing capability document, or null when hash
     * mode is unavailable for this install.
     *
     * The probe is per-organization, not per-asset, so an N-asset run fetches it
     * at most once: a request-local memo collapses the run, and a short
     * transient keyed by (api_base_url + api key) - the same scoping the license
     * catalog uses - collapses back-to-back requests.
     *
     * Fail-closed and unchanged from the per-asset probe: an unreachable
     * endpoint, a non-200, an unreadable body, a foreign protocol, or
     * enabled:false all yield null and are memoized for this request only. A
     * negative answer is never persisted, so an organization that gains the
     * capability is picked up on its next request rather than after a TTL. Only
     * a positive document is cached, and a stale positive is self-correcting:
     * prepare answers FEATURE_NOT_AVAILABLE and the caller falls back to byte
     * mode exactly as it does today.
     *
     * Per-MIME eligibility stays with the caller because it varies per asset
     * while this document does not.
     *
     * @param array<string,string> $headers
     * @return array<string,mixed>|null
     */
    private function hash_signing_capabilities(string $api_base, string $api_key, array $headers): ?array
    {
        $scope = hash('sha256', $api_base . '|' . $api_key);
        if (array_key_exists($scope, $this->hash_capabilities)) {
            return $this->hash_capabilities[$scope];
        }

        $cache_key = 'encypher_hash_capabilities_' . $scope;
        $cached = self::read_cached_hash_capabilities($cache_key);
        if (is_array($cached) && ! empty($cached['enabled'])) {
            $this->hash_capabilities[$scope] = $cached;
            return $cached;
        }

        // Memoize the negative answer first: every early return below must cost
        // one probe per request, not one per asset.
        $this->hash_capabilities[$scope] = null;
        $response = $this->guarded_remote_get(
            $api_base . '/sign/media/hash/capabilities',
            [
                'timeout' => 3,
                'headers' => $headers,
            ]
        );
        if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
            return null;
        }
        $capabilities = self::hash_api_payload($response);
        if (
            ! is_array($capabilities)
            || self::PREPARED_SIGNING_PROTOCOL !== ($capabilities['protocol'] ?? null)
            || empty($capabilities['enabled'])
        ) {
            return null;
        }

        $this->hash_capabilities[$scope] = $capabilities;
        self::write_cached_hash_capabilities($cache_key, $capabilities);
        return $capabilities;
    }

    /** @return mixed Transient value, or false where transients are unavailable. */
    private static function read_cached_hash_capabilities(string $cache_key)
    {
        return function_exists(__NAMESPACE__ . '\\get_transient') || function_exists('get_transient')
            ? get_transient($cache_key)
            : false;
    }

    /** @param array<string,mixed> $capabilities */
    private static function write_cached_hash_capabilities(string $cache_key, array $capabilities): void
    {
        if (function_exists(__NAMESPACE__ . '\\set_transient') || function_exists('set_transient')) {
            set_transient($cache_key, $capabilities, self::HASH_CAPABILITIES_TTL);
        }
    }

    /** @return array<string,mixed>|null */
    private static function hash_api_payload(array $response): ?array
    {
        $decoded = json_decode(wp_remote_retrieve_body($response), true);
        if (! is_array($decoded)) {
            return null;
        }
        if (isset($decoded['data']) && is_array($decoded['data'])) {
            return $decoded['data'];
        }
        return $decoded;
    }
    private static function valid_normalized_hash_geometry(array $payload, string $mime_type, bool $require_output_span): bool
    {
        $span = $payload['carrier_span'] ?? null;
        if (! self::valid_prepared_byte_range($span) || 0 !== $span['offset']) {
            return false;
        }
        if ($require_output_span) {
            $output_span = $payload['carrier_output_span'] ?? null;
            if (
                ! self::valid_prepared_byte_range($output_span)
                || $output_span['length'] !== $span['length']
            ) {
                return false;
            }
        }
        $binding_algorithm = $payload['binding_algorithm'] ?? null;
        if (HashMediaPlanner::binding_algorithm_for_mime($mime_type) !== $binding_algorithm) {
            return false;
        }
        $exclusions = $payload['exclusions'] ?? null;
        if ('c2pa.hash.bmff.v3' === $binding_algorithm) {
            return self::BMFF_EXCLUSIONS === $exclusions;
        }
        if ('c2pa.hash.data' !== $binding_algorithm) {
            return false;
        }
        return is_array($exclusions)
            && 1 === count($exclusions)
            && is_array($exclusions[0] ?? null)
            && 2 === count($exclusions[0])
            && is_int($exclusions[0][0] ?? null)
            && is_int($exclusions[0][1] ?? null)
            && $exclusions[0][0] >= 0
            && $exclusions[0][1] > 0
            && (! $require_output_span || $exclusions[0] === [$output_span['offset'], $output_span['length']]);
    }


    /**
     * Sign a post's hash-mode attachments in bounded concurrent waves.
     *
     * Local-hash signing spends nearly all of its per-asset time waiting on two
     * API round trips - prepare, then finalize - that carry container structure
     * and digests rather than media bytes. Signed serially, an N-asset post pays
     * 2N sequential waits plus one capability probe per asset. Here each asset
     * runs on its own Fiber; when it needs a prepared-signing request it
     * suspends, the driver issues every suspended asset's request together
     * through the curl_multi transport WordPress already bundles, then resumes
     * each asset with its own response. An eligible post costs one probe plus
     * two network waves instead of 3N serial round trips.
     *
     * Nothing about an asset's own sequence changes, so every safety property is
     * preserved: it still holds its own exclusive inode lock
     * (HashMediaWriter::with_locked_source), its own commit lease and CAS
     * heartbeat, and its own fresh-plan retry state, all live on its fiber's
     * stack. Per-attachment edit-capability filtering happened before this list
     * was built, and the order it was built in is preserved through every wave.
     * Assets that are not hash-eligible never suspend, so they sign through the
     * unchanged serial route from inside their fiber.
     *
     * @param array<int,mixed> $attachment_ids Deterministically ordered ids.
     */
    private function sign_attachments_in_waves(array $attachment_ids): void
    {
        $ids = [];
        foreach ($attachment_ids as $candidate) {
            $attachment_id = (int) $candidate;
            if ($attachment_id > 0 && ! in_array($attachment_id, $ids, true)) {
                $ids[] = $attachment_id;
            }
        }
        if ([] === $ids) {
            return;
        }
        if (1 === count($ids) || ! $this->batched_hash_signing_available()) {
            foreach ($ids as $attachment_id) {
                $this->sign_attachment($attachment_id);
            }
            return;
        }

        foreach (array_chunk($ids, self::batched_hash_signing_concurrency()) as $wave) {
            if (1 === count($wave)) {
                $this->sign_attachment($wave[0]);
                continue;
            }
            $this->run_hash_signing_wave($wave);
        }
    }

    /**
     * Whether hash-mode signing may batch its network waves.
     *
     * Fails safe to the unchanged serial route on any of: the operator kill
     * switch, a PHP build without Fibers (the plugin floor is PHP 8.0, Fibers
     * arrived in 8.1), a WordPress without the bundled curl_multi API, or a
     * caller already running on a fiber.
     */
    private function batched_hash_signing_available(): bool
    {
        if (! apply_filters('encypher_hash_signing_batch_enabled', true)) {
            return false;
        }
        return class_exists('Fiber')
            && null === \Fiber::getCurrent()
            && null !== self::hash_wave_transport();
    }

    /**
     * The WordPress-bundled Requests class exposing curl_multi batching.
     *
     * WordPress 6.2 namespaced Requests; 6.0 and 6.1 - still inside this
     * plugin's supported floor - ship the pre-namespace class under the same
     * request_multiple() contract. Neither present means no batching.
     */
    private static function hash_wave_transport(): ?string
    {
        foreach (['WpOrg\\Requests\\Requests', 'Requests'] as $transport) {
            if (class_exists($transport) && method_exists($transport, 'request_multiple')) {
                return $transport;
            }
        }
        return null;
    }

    /**
     * In-flight asset ceiling for one wave.
     *
     * This is a resource bound, not a tuning knob: every in-flight asset pins an
     * exclusive inode lock, a commit lease, a private source snapshot, and an
     * output temp file for the whole wave, so an unbounded fan-out over a large
     * media library would hold the entire library open at once.
     */
    private static function batched_hash_signing_concurrency(): int
    {
        $limit = (int) apply_filters('encypher_hash_signing_batch_concurrency', 4);
        return max(1, min(8, $limit));
    }

    /**
     * Drive one bounded wave of attachments to completion.
     *
     * Each round resumes every asset that is waiting on the network, so the
     * first round issues the wave's prepares together and the next issues its
     * finalizes together. A retrying asset simply joins a later round.
     *
     * @param array<int,int> $attachment_ids
     */
    private function run_hash_signing_wave(array $attachment_ids): void
    {
        $fibers = [];
        foreach ($attachment_ids as $attachment_id) {
            $fibers[$attachment_id] = new \Fiber(
                function () use ($attachment_id): void {
                    $this->sign_attachment($attachment_id);
                }
            );
        }

        $restore = $this->hash_wave_active;
        $this->hash_wave_active = true;
        try {
            $pending = [];
            foreach ($fibers as $attachment_id => $fiber) {
                $request = $this->advance_hash_signing_fiber($attachment_id, $fiber, null);
                if (null !== $request) {
                    $pending[$attachment_id] = $request;
                }
            }
            while ([] !== $pending) {
                $responses = $this->dispatch_hash_signing_wave($pending);
                $next = [];
                foreach (array_keys($pending) as $attachment_id) {
                    $request = $this->advance_hash_signing_fiber(
                        $attachment_id,
                        $fibers[$attachment_id],
                        $responses[$attachment_id] ?? new \WP_Error(
                            'hash_wave_dispatch_failed',
                            'The batched hash-signing request produced no response.'
                        )
                    );
                    if (null !== $request) {
                        $next[$attachment_id] = $request;
                    }
                }
                $pending = $next;
            }
        } finally {
            // The loop above resumes every suspended fiber, so none should be
            // left waiting here. If one ever is, PHP unwinds it on destruction
            // and runs its finally blocks, releasing that asset's inode lock,
            // lease, and private temp files rather than leaking them.
            $this->hash_wave_active = $restore;
        }
    }

    /**
     * Start or resume one asset's fiber and return its next suspended request.
     *
     * A fiber that throws is contained here so one asset can never abort its
     * siblings; sign_attachment() already records the per-attachment error and
     * releases its lease inside its own finally block.
     *
     * @param array<string,mixed>|\WP_Error|null $resume
     * @return array<string,mixed>|null Null once this asset needs no more network.
     */
    private function advance_hash_signing_fiber(int $attachment_id, \Fiber $fiber, $resume): ?array
    {
        try {
            $request = $fiber->isStarted() ? $fiber->resume($resume) : $fiber->start();
        } catch (\Throwable $error) {
            $this->debug_log(sprintf(
                'Batched attachment signing stopped for %d: %s',
                $attachment_id,
                $error->getMessage()
            ));
            return null;
        }
        if ($fiber->isTerminated() || ! is_array($request) || ! isset($request['url'], $request['args'])) {
            return null;
        }
        return $request;
    }

    /**
     * Issue every suspended asset's request as one curl_multi wave.
     *
     * The wave bypasses wp_remote_post(), so this method re-applies both
     * properties guarded_remote_post() owns. ApiUrlGuard::is_safe() runs per URL
     * before dispatch, and a redirect is never followed here: it is replayed
     * through the guarded serial wrapper, which stays the plugin's single
     * redirect-policy chokepoint. Leases are renewed before the wave, as each
     * response lands, and after the wave, because no fiber runs while
     * curl_multi blocks - so a slow sibling cannot expire a fast asset's lease.
     *
     * @param array<int,array<string,mixed>> $requests
     * @return array<int,array<string,mixed>|\WP_Error>
     */
    private function dispatch_hash_signing_wave(array $requests): array
    {
        $renew = static function () use ($requests): void {
            foreach ($requests as $request) {
                if (is_callable($request['heartbeat'] ?? null)) {
                    ($request['heartbeat'])();
                }
            }
        };
        $renew();

        $responses = [];
        $batch = [];
        foreach ($requests as $attachment_id => $request) {
            if (! ApiUrlGuard::is_safe((string) $request['url'])) {
                $responses[$attachment_id] = new \WP_Error(
                    'unsafe_api_base_url',
                    __('The Encypher API URL is not allowed (must be HTTPS and a public host).', 'encypher-provenance'),
                    ['status' => 400]
                );
                continue;
            }
            $batch[$attachment_id] = self::hash_wave_request_spec($request);
        }

        if ([] !== $batch) {
            $transport = self::hash_wave_transport();
            try {
                $results = $transport::request_multiple($batch, self::hash_wave_options($renew));
            } catch (\Throwable $error) {
                $results = array_fill_keys(
                    array_keys($batch),
                    new \WP_Error('http_request_failed', $error->getMessage())
                );
            }
            foreach ($results as $attachment_id => $result) {
                $responses[$attachment_id] = is_wp_error($result)
                    ? $result
                    : $this->hash_wave_response($requests[$attachment_id], $result);
            }
        }

        $renew();
        return $responses;
    }

    /**
     * Translate one guarded-request argument array into a Requests batch entry.
     *
     * @param array<string,mixed> $request
     * @return array<string,mixed>
     */
    private static function hash_wave_request_spec(array $request): array
    {
        $args = is_array($request['args'] ?? null) ? $request['args'] : [];
        $timeout = max(1, (int) ($args['timeout'] ?? 30));
        $limit = $args['limit_response_size'] ?? null;
        return [
            'url' => (string) $request['url'],
            'headers' => is_array($args['headers'] ?? null) ? $args['headers'] : [],
            'data' => is_string($args['body'] ?? null) ? $args['body'] : '',
            'type' => 'GET' === strtoupper((string) ($request['method'] ?? 'POST')) ? 'GET' : 'POST',
            'options' => [
                'timeout' => $timeout,
                'connect_timeout' => $timeout,
                'follow_redirects' => false,
                'max_bytes' => is_int($limit) ? $limit : false,
                'useragent' => self::hash_wave_useragent(),
            ],
        ];
    }

    /**
     * Wave-wide Requests options.
     *
     * The completion hook renews every in-flight lease as each response lands,
     * which is the only progress signal curl_multi exposes.
     *
     * @return array<string,mixed>
     */
    private static function hash_wave_options(callable $renew): array
    {
        foreach (['WpOrg\\Requests\\Hooks', 'Requests_Hooks'] as $hooks_class) {
            if (! class_exists($hooks_class)) {
                continue;
            }
            $hooks = new $hooks_class();
            $hooks->register('multiple.request.complete', static function () use ($renew): void {
                $renew();
            });
            return ['hooks' => $hooks];
        }
        return [];
    }

    /**
     * Send the same user agent wp_remote_post() would, so the wave is not a
     * different client to the API edge than the serial path is.
     */
    private static function hash_wave_useragent(): string
    {
        $version = isset($GLOBALS['wp_version']) ? (string) $GLOBALS['wp_version'] : '';
        $home = function_exists('home_url') ? (string) home_url() : '';
        return (string) apply_filters('http_headers_useragent', 'WordPress/' . $version . '; ' . $home, '');
    }

    /**
     * Normalize one wave result into the wp_remote_* response shape the hash
     * transport already parses.
     *
     * @param array<string,mixed> $request
     * @param mixed $result A Requests response, or a transport exception.
     * @return array<string,mixed>|\WP_Error
     */
    private function hash_wave_response(array $request, $result)
    {
        if (! is_object($result) || ! isset($result->status_code)) {
            return new \WP_Error(
                'http_request_failed',
                $result instanceof \Throwable
                    ? $result->getMessage()
                    : 'The Encypher API connection failed.'
            );
        }
        $status = (int) $result->status_code;
        $args = is_array($request['args'] ?? null) ? $request['args'] : [];
        if ($status >= 300 && $status < 400) {
            return 'GET' === strtoupper((string) ($request['method'] ?? 'POST'))
                ? $this->guarded_remote_get((string) $request['url'], $args)
                : $this->guarded_remote_post((string) $request['url'], $args);
        }
        return [
            'headers' => $result->headers ?? [],
            'body' => (string) ($result->body ?? ''),
            'response' => [
                'code' => $status,
                'message' => function_exists('get_status_header_desc')
                    ? get_status_header_desc($status)
                    : '',
            ],
            'cookies' => [],
            'filename' => null,
        ];
    }

    /**
     * Sign a standalone media attachment's ORIGINAL full-size file through the
     * shared /sign/media transport and record the result on the attachment.
     *
     * Guards: skips offloaded/missing local files and MIME types the media sign
     * pipeline does not support (the same allow-list the featured-image path
     * uses). Per-asset rights ({@see _encypher_rights} attachment meta) take
     * precedence over the site/org default. On success stores
     * _encypher_provenance_status/_encypher_image_record_id/
     * _encypher_image_manifest_url; on failure records _encypher_last_sign_error
     * so the media UI can surface a "Needs attention" state.
     */
    public function sign_attachment(int $attachment_id): void
    {
        $lease_token = SigningStatus::claim($attachment_id, 'media-worker');
        $heartbeat = static fn (): bool => SigningStatus::heartbeat($attachment_id, $lease_token);
        try {
            if ('attachment' !== get_post_type($attachment_id)) {
                $this->record_attachment_sign_error(
                    $attachment_id,
                    'invalid_attachment',
                    __('The requested media attachment no longer exists.', 'encypher-provenance')
                );
                return;
            }

            $file_path = get_attached_file($attachment_id);
            if (!$file_path || !file_exists($file_path)) {
                $this->record_attachment_sign_error(
                    $attachment_id,
                    'media_file_unavailable',
                    __('The file for this attachment is not available on this server (it may be offloaded to remote storage).', 'encypher-provenance')
                );
                return;
            }

            $mime_type = (string) get_post_mime_type($attachment_id);
            if (! self::is_signable_media_mime($mime_type)) {
                $this->record_attachment_sign_error(
                    $attachment_id,
                    'unsupported_media_type',
                    sprintf(
                        /* translators: %s: media MIME type. */
                        __('The media type %s cannot be signed by Encypher.', 'encypher-provenance'),
                        $mime_type
                    )
                );
                return;
            }

            $settings = get_option('encypher_provenance_settings', []);
            if (!is_array($settings)) {
                $settings = [];
            }

            $media_context = [
                'title'        => function_exists('get_the_title') ? get_the_title($attachment_id) : '',
                'original_url' => wp_get_attachment_url($attachment_id) ?: '',
            ];
            $settings = $this->apply_media_rights_projection(
                $attachment_id,
                0,
                $settings,
                $media_context
            );
            $settings['install_id'] = $this->ensure_local_install_id($settings);
            $inventory_contract = $this->claim_media_inventory_contract(
                $attachment_id,
                $file_path,
                $mime_type,
                $media_context,
                $settings
            );
            if (is_wp_error($inventory_contract)) {
                $this->record_attachment_sign_error(
                    $attachment_id,
                    (string) $inventory_contract->get_error_code(),
                    $inventory_contract->get_error_message()
                );
                return;
            }
            $media_context['asset_id'] = $inventory_contract['logical_asset_id'];
            $media_context['operation_key'] = $inventory_contract['operation_key'];
            $media_context['_operation_attachment_id'] = $attachment_id;
            $media_context['_hash_plan_attempt'] = $inventory_contract['hash_plan_attempt'];
            $media_context['_hash_prepared_state'] = $inventory_contract['hash_prepared_state'];
            $media_context['source'] = 'wordpress';
            $media_context['source_metadata'] = [
                'wordpress_install_id' => $settings['install_id'],
                'wordpress_post_id' => $attachment_id,
            ];

            $result = $this->post_sign_media($file_path, $mime_type, $media_context, $settings, $heartbeat);
            if (is_wp_error($result)) {
                if ('signing_superseded' === $result->get_error_code()) {
                    return;
                }
                $this->record_attachment_sign_error(
                    $attachment_id,
                    (string) $result->get_error_code(),
                    $result->get_error_message()
                );
                $this->debug_log(sprintf('Attachment signing failed for %d: %s', $attachment_id, $result->get_error_message()));
                return;
            }

            $record_id    = (string) ($result['record_id'] ?? '');
            $manifest_url = (string) ($result['manifest_url'] ?? '');
            $signed_bytes = (string) ($result['signed_bytes'] ?? '');
            $hash_committed = ! empty($result['hash_committed']);
            if ('' === $record_id || (! $hash_committed && '' === $signed_bytes)) {
                if ($hash_committed) {
                    $this->release_attachment_commit_lock(
                        $attachment_id,
                        (string) ($result['_attachment_commit_token'] ?? '')
                    );
                }
                $this->record_attachment_sign_error(
                    $attachment_id,
                    'encypher_media_incomplete_response',
                    __('Encypher did not return a complete signed media artifact. Retry signing this attachment.', 'encypher-provenance')
                );
                return;
            }

            $success_meta = [
                '_encypher_image_record_id' => sanitize_text_field($record_id),
                '_encypher_image_manifest_url' => esc_url_raw($manifest_url),
                '_encypher_provenance_status' => 'signed',
                '_encypher_signed_at' => current_time('mysql'),
                '_encypher_signed_hash' => sanitize_text_field((string) ($result['signed_hash'] ?? '')),
                '_encypher_c2pa_instance_id' => sanitize_text_field((string) ($result['c2pa_instance_id'] ?? '')),
            ];
            $commit = $hash_committed
                ? $this->commit_prepared_attachment(
                    $attachment_id,
                    (string) ($result['signed_hash'] ?? ''),
                    $success_meta + [
                        '_encypher_rights' => ! empty($result['rights']) && is_array($result['rights'])
                            ? wp_json_encode($result['rights'])
                            : null,
                    ],
                    (string) $inventory_contract['operation_key'],
                    (string) ($result['_attachment_commit_token'] ?? '')
                )
                : $this->write_and_commit_signed_attachment(
                    $attachment_id,
                    $signed_bytes,
                    (string) ($result['original_bytes'] ?? ''),
                    $success_meta,
                    $result
                );
            if (! $commit['written']) {
                if (in_array(($commit['disposition'] ?? ''), ['superseded_by_current_commit', 'superseded_by_pending_commit'], true)) {
                    // A concurrent signer owns the byte-plus-metadata commit.
                    // This stale worker is a no-op and must not poison the
                    // winner while its success metadata is still in flight.
                    return;
                }
                $write_error = $commit['error'];
                $this->record_attachment_sign_error(
                    $attachment_id,
                    is_wp_error($write_error) ? (string) $write_error->get_error_code() : 'write_failed',
                    is_wp_error($write_error) ? $write_error->get_error_message() : __('The signed media file could not be written.', 'encypher-provenance')
                );
                return;
            }
            $this->complete_media_inventory_operation($attachment_id, (string) $inventory_contract['operation_key']);
            if (is_wp_error($commit['error'])) {
                $this->debug_log(sprintf('VIP media purge failed for attachment %d: %s', $attachment_id, $commit['error']->get_error_message()));
            }
            $this->debug_log(sprintf('Attachment %d signed: record_id=%s', $attachment_id, $record_id));

            if (class_exists(Admin::class)) {
                Admin::flush_local_coverage_cache();
            }
        } catch (\Throwable $error) {
            $this->record_attachment_sign_error(
                $attachment_id,
                'media_signing_exception',
                __('Media signing stopped unexpectedly. Retry this attachment; contact support if the problem continues.', 'encypher-provenance')
            );
            $this->debug_log(sprintf('Attachment signing exception for %d: %s', $attachment_id, $error->getMessage()));
        } finally {
            SigningStatus::release($attachment_id, $lease_token);
        }
    }

    /**
     * Verify provenance of a media file through the Encypher verification API.
     *
     * Routes detection/validation to POST {api_base}/public/verify/media, which
     * reads C2PA across the full supported format matrix via the c2pa Reader
     * (far broader than the local JUMBF presence scan). The endpoint is public;
     * the API key is sent when available for rate-limit attribution but is not
     * required.
     *
     * @param string      $file_path Absolute path to the media file.
     * @param string      $mime_type MIME type hint (the API re-sniffs from bytes).
     * @param string|null $filename  Original filename (aids server-side sniffing).
     *
     * @return array|WP_Error Normalized detection result, or WP_Error on transport
     *                        failure so callers can fall back to local detection.
     */
    public function verify_media_file(string $file_path, string $mime_type, ?string $filename = null)
    {
        if (! self::full_asset_upload_allowed()) {
            return new WP_Error('full_asset_upload_disabled', 'Remote media verification is disabled because full asset uploads are off.');
        }
        if (! file_exists($file_path) || ! is_readable($file_path)) {
            return new WP_Error('file_unreadable', 'Media file is not readable.');
        }

        $max_bytes = (int) apply_filters('encypher_verify_media_max_bytes', 64 * 1024 * 1024);
        $file_size = filesize($file_path);
        if ($file_size === false || $file_size > $max_bytes) {
            return new WP_Error('file_too_large', 'Media file exceeds the verification size limit.');
        }

        $data = file_get_contents($file_path); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local read of an uploaded media file for verification (size-bounded above), not a remote fetch.
        if ($data === false) {
            return new WP_Error('file_read_failed', 'Could not read media file for verification.');
        }

        $filename = ($filename !== null && $filename !== '') ? $filename : basename($file_path);
        return $this->verify_media_bytes($data, $mime_type, $filename);
    }

    /**
     * Verify the exact bytes served at a reader-facing media URL.
     *
     * @return array|WP_Error
     */
    public function verify_media_url(string $url, string $mime_type, string $filename, int $attachment_id)
    {
        if (! self::full_asset_upload_allowed()) {
            return new WP_Error('full_asset_upload_disabled', 'Public media verification is disabled because full asset uploads are off.');
        }
        $max_bytes = (int) apply_filters('encypher_verify_media_max_bytes', 64 * 1024 * 1024);
        $response  = wp_safe_remote_get(
            $url,
            [
                // Reader-triggered and rate-limited. Verification must inspect
                // public delivery bytes, not the potentially newer local file.
                'timeout'             => 20, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Explicit reader action fetches bounded public media bytes before verification; the route is rate-limited and never page-render blocking.
                'redirection'         => 3,
                'limit_response_size' => $max_bytes + 1,
            ]
        );
        if (is_wp_error($response)) {
            return new WP_Error(
                'media_delivery_unavailable',
                __('Public media could not be read for verification.', 'encypher-provenance'),
                ['status' => 502]
            );
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        $data   = wp_remote_retrieve_body($response);
        if (200 !== $status || ! is_string($data) || '' === $data) {
            return new WP_Error(
                'media_delivery_unavailable',
                __('Public media could not be read for verification.', 'encypher-provenance'),
                ['status' => 502]
            );
        }
        if (strlen($data) > $max_bytes) {
            return new WP_Error(
                'file_too_large',
                __('Public media exceeds the verification size limit.', 'encypher-provenance'),
                ['status' => 413]
            );
        }

        $expected_hash = (string) get_post_meta($attachment_id, '_encypher_signed_file_hash', true);
        if (0 === strpos($expected_hash, 'sha256:')) {
            $actual_hash = 'sha256:' . hash('sha256', $data);
            if (! hash_equals($expected_hash, $actual_hash)) {
                return new WP_Error(
                    'media_delivery_mismatch',
                    __('Public media does not match the signed original.', 'encypher-provenance'),
                    ['status' => 409]
                );
            }
        }

        return $this->verify_media_bytes($data, $mime_type, $filename);
    }

    /**
     * Full media bytes may leave WordPress only when the setting is absent
     * (backwards-compatible default) or explicitly enabled.
     */
    private static function full_asset_upload_allowed(): bool
    {
        $settings = get_option('encypher_provenance_settings', []);
        return ! is_array($settings)
            || ! array_key_exists('allow_full_asset_upload', $settings)
            || ! empty($settings['allow_full_asset_upload']);
    }

    /**
     * Submit bounded media bytes to the public C2PA verifier.
     *
     * @return array|WP_Error
     */
    protected function verify_media_bytes(string $data, string $mime_type, string $filename)
    {
        if (! self::full_asset_upload_allowed()) {
            return new WP_Error('full_asset_upload_disabled', 'Remote media verification is disabled because full asset uploads are off.');
        }

        $settings = get_option('encypher_provenance_settings', []);
        $api_key  = isset($settings['api_key']) ? (string) $settings['api_key'] : '';
        $api_base = rtrim($settings['api_base_url'] ?? 'https://api.encypher.com/api/v1', '/');

        $filename = sanitize_file_name($filename);
        $mime_type = (string) preg_replace('/[\r\n]+/', '', $mime_type);
        $boundary = wp_generate_password(24, false);
        $body  = "--{$boundary}\r\n";
        $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
        $body .= "Content-Type: {$mime_type}\r\n\r\n";
        $body .= $data . "\r\n";
        $body .= "--{$boundary}--";

        $headers = ['Content-Type' => 'multipart/form-data; boundary=' . $boundary];
        if ('' !== $api_key) {
            $headers['Authorization'] = 'Bearer ' . $api_key;
        }

        $response = $this->guarded_remote_post(
            $api_base . '/public/verify/media',
            [
                'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Explicit reader action uploads bounded media bytes for verification; the route is rate-limited and never page-render blocking.
                'headers' => $headers,
                'body'    => $body,
            ]
        );

        if (is_wp_error($response)) {
            return $response;
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        if ($status !== 200) {
            return new WP_Error('verify_media_http_' . $status, sprintf('Verification API returned HTTP %d.', $status));
        }

        $decoded = json_decode(wp_remote_retrieve_body($response), true);
        if (! is_array($decoded)) {
            return new WP_Error('verify_media_bad_response', 'Verification API returned an unreadable response.');
        }

        return self::normalize_media_verify_response($decoded, $mime_type);
    }

    /**
     * Normalize the /public/verify/media response envelope into the detection
     * shape stored on attachment meta. Pure function (no I/O) for testability.
     *
     * @param array  $decoded   Decoded JSON body from the verification API.
     * @param string $mime_type MIME hint used as the format fallback.
     *
     * @return array<string,mixed>
     */
    public static function normalize_media_verify_response(array $decoded, string $mime_type): array
    {
        $payload = (isset($decoded['data']) && is_array($decoded['data'])) ? $decoded['data'] : $decoded;

        $instance_id = isset($payload['c2pa_instance_id']) ? (string) $payload['c2pa_instance_id'] : '';
        $manifest = $payload['c2pa_manifest'] ?? null;
        $error = $payload['error'] ?? null;
        $success = ! empty($payload['success']);

        // A completed request is not proof that a manifest was present.
        $reason_code = strtoupper((string) ($payload['reason_code'] ?? ''));
        $detected = empty($error)
            && 'NO_C2PA_MANIFEST' !== $reason_code
            && ($success || '' !== $instance_id || (is_array($manifest) && ! empty($manifest)));

        $signer_trust = self::normalize_signer_trust_tuple($payload);
        $signer = (string) ($signer_trust['signer_name'] ?? '');
        $signer_id = (string) ($signer_trust['signer_id'] ?? '');
        $signed_at = isset($payload['signed_at']) ? (string) $payload['signed_at'] : '';
        $rights = ! empty($payload['valid'])
            && isset($payload['rights'])
            && is_array($payload['rights'])
            && ! empty($payload['rights'])
                ? $payload['rights']
                : null;

        $normalized = [
            'detected'         => $detected,
            'valid'            => ! empty($payload['valid']),
            'media_type'       => isset($payload['media_type']) ? (string) $payload['media_type'] : '',
            'format'           => strtolower($mime_type),
            'signer'           => $signer,
            'signer_name'      => $signer,
            'signing_identity' => $signer,
            'signer_id'        => $signer_id,
            'trust_level'      => $signer_trust['trust_level'],
            'ca_backed'        => $signer_trust['ca_backed'],
            'trusted_signer'   => $signer_trust['trusted_signer'],
            'signed_at'        => $signed_at,
            'verified_at'      => isset($payload['verified_at']) ? (string) $payload['verified_at'] : $signed_at,
            'instance_id'      => $instance_id,
            'reason_code'      => isset($payload['reason_code']) ? (string) $payload['reason_code'] : '',
            'source'           => 'api',
            'summary'          => self::summarize_media_verify_payload($payload, strtolower($mime_type)),
            'rights'           => $rights,
            'details'          => $payload,
        ];
        if (isset($payload['reader_verdict']) && is_array($payload['reader_verdict'])) {
            $normalized['reader_verdict'] = $payload['reader_verdict'];
        }
        return $normalized;
    }

    /**
     * Reduce a verification payload to the facts an editor needs first.
     *
     * @return array<string,mixed>
     */
    public static function summarize_media_verify_payload(array $payload, string $mime_type): array
    {
        $manifest_store = isset($payload['c2pa_manifest']) && is_array($payload['c2pa_manifest'])
            ? $payload['c2pa_manifest']
            : [];
        $active_label = isset($manifest_store['active_manifest']) ? (string) $manifest_store['active_manifest'] : '';
        $manifests = isset($manifest_store['manifests']) && is_array($manifest_store['manifests'])
            ? $manifest_store['manifests']
            : [];
        $active = '' !== $active_label && isset($manifests[$active_label]) && is_array($manifests[$active_label])
            ? $manifests[$active_label]
            : [];

        $actions = [];
        foreach (($active['assertions'] ?? []) as $assertion) {
            if (! is_array($assertion) || 0 !== strpos((string) ($assertion['label'] ?? ''), 'c2pa.actions')) {
                continue;
            }
            $data = isset($assertion['data']) && is_array($assertion['data']) ? $assertion['data'] : [];
            foreach (($data['actions'] ?? []) as $action) {
                if (is_array($action)) {
                    $actions[] = $action;
                }
            }
        }

        $source_type_uri = '';
        $software_agent = '';
        $action_names = [];
        foreach ($actions as $action) {
            $name = (string) ($action['action'] ?? '');
            if ('' !== $name) {
                $action_names[] = $name;
            }
            if ('' === $source_type_uri && ! empty($action['digitalSourceType'])) {
                $source_type_uri = (string) $action['digitalSourceType'];
            }
            if ('' === $software_agent && isset($action['softwareAgent'])) {
                if (is_array($action['softwareAgent'])) {
                    $software_agent = trim(
                        (string) ($action['softwareAgent']['name'] ?? '')
                        . (''
                            !== (string) ($action['softwareAgent']['version'] ?? '')
                            ? ' ' . (string) $action['softwareAgent']['version']
                            : '')
                    );
                } elseif (is_string($action['softwareAgent'])) {
                    $software_agent = $action['softwareAgent'];
                }
            }
        }

        $source_type = self::plain_source_type($source_type_uri);
        $signer_tuple = self::normalize_signer_trust_tuple($payload);
        $signer = (string) ($signer_tuple['signer_name'] ?? '');
        $valid = ! empty($payload['valid']);
        if (! $valid) {
            $statement = __('A C2PA record was found, but its signature could not be verified.', 'encypher-provenance');
        } elseif ('Created with generative AI' === $source_type) {
            $statement = __('Verified C2PA record: created with generative AI.', 'encypher-provenance');
        } elseif ('Includes generative AI' === $source_type) {
            $statement = __('Verified C2PA record: this asset includes generative AI.', 'encypher-provenance');
        } elseif ('' !== $signer) {
            $statement = sprintf(
                /* translators: %s: organization or tool that signed the source asset. */
                __('Verified C2PA record signed by %s.', 'encypher-provenance'),
                $signer
            );
        } else {
            $statement = __('Verified C2PA provenance record.', 'encypher-provenance');
        }

        $claim_generator = '';
        if (isset($active['claim_generator_info']) && is_array($active['claim_generator_info'])) {
            $claim_generator = (string) ($active['claim_generator_info']['name'] ?? '');
        }
        $version = isset($payload['version_verdict']) && is_array($payload['version_verdict'])
            ? (string) ($payload['version_verdict']['declared_spec_version'] ?? '')
            : '';

        return [
            'statement'       => $statement,
            'valid'           => $valid,
            'signer'          => $signer,
            'trusted_signer'  => ! empty($signer_tuple['trusted_signer']),
            'source_type'     => $source_type,
            'source_type_uri' => $source_type_uri,
            'software_agent'  => $software_agent,
            'claim_generator' => $claim_generator,
            'actions'         => array_values(array_unique($action_names)),
            'signed_at'       => isset($payload['signed_at']) ? (string) $payload['signed_at'] : '',
            'spec_version'    => $version,
            'format'          => $mime_type,
            'has_rights'      => $valid && isset($payload['rights']) && is_array($payload['rights']) && ! empty($payload['rights']),
        ];
    }

    private static function plain_source_type(string $uri): string
    {
        if (str_ends_with($uri, '/trainedAlgorithmicMedia')) {
            return 'Created with generative AI';
        }
        if (str_ends_with($uri, '/compositeWithTrainedAlgorithmicMedia')) {
            return 'Includes generative AI';
        }
        if (str_ends_with($uri, '/digitalCapture')) {
            return 'Captured by a camera or recording device';
        }
        if (str_ends_with($uri, '/humanEdits')) {
            return 'Edited by a person';
        }
        return '';
    }

    /**
     * WS3: whether an attachment's on-disk bytes still match what we signed.
     *
     * Image/audio/video optimizers can rewrite a media file after the plugin
     * wrote the C2PA-signed bytes, silently breaking the hard binding. We
     * record a SHA-256 of the exact signed bytes at write time
     * (_encypher_signed_file_hash); this compares the current file against it.
     *
     * @param int $attachment_id Attachment post ID.
     * @return bool True if intact or unknown (no recorded hash); false if the
     *              file changed since signing and must be re-signed.
     */
    private function media_signature_intact(int $attachment_id): bool
    {
        $expected = (string) get_post_meta($attachment_id, '_encypher_signed_file_hash', true);
        if ('' === $expected) {
            // No recorded signed-bytes hash (legacy signature or non-composite
            // path) — cannot prove mutation, so treat as intact to avoid churn.
            return true;
        }
        $file_path = get_attached_file($attachment_id);
        if (!$file_path || !file_exists($file_path)) {
            return true;
        }
        $current = hash_file('sha256', $file_path);
        if ($current === false) {
            return true;
        }
        return hash_equals($expected, 'sha256:' . $current);
    }

    /**
     * Inject a C2PA-Manifest-URL response header for single post/page responses
     * that have a signed featured image.
     *
     * Hooked on send_headers.
     */
    public function inject_image_provenance_header(): void
    {
        // Only inject on single post/page responses
        if (!is_singular()) {
            return;
        }

        $post_id = get_the_ID();
        if (!$post_id) {
            return;
        }

        $manifest_url = $this->get_image_manifest_url((int) $post_id);
        if ('' !== $manifest_url) {
            header('C2PA-Manifest-URL: ' . esc_url($manifest_url));
        }
    }

    /**
     * Return the CDN image record ID stored for a post's featured image.
     */
    public function get_image_record_id(int $post_id): string
    {
        $attachment_id = (int) get_post_thumbnail_id($post_id);
        return $attachment_id > 0
            ? (string) get_post_meta($attachment_id, '_encypher_image_record_id', true)
            : '';
    }

    /**
     * Return the CDN manifest URL stored for a post's featured image.
     */
    public function get_image_manifest_url(int $post_id): string
    {
        $attachment_id = (int) get_post_thumbnail_id($post_id);
        return $attachment_id > 0
            ? (string) get_post_meta($attachment_id, '_encypher_image_manifest_url', true)
            : '';
    }
}
