<?php
/**
 * Protected publication, cancellation, live delivery checks, and receipts.
 *
 * Publication assurance is its own status dimension. A held publication owns a
 * `publication_generation`, and both the terminal commit and an operator
 * cancellation claim one option row named for that generation through a single
 * atomic insert. Exactly one wins: a late signing response cannot publish after
 * a cancellation, and a cancellation cannot claim rollback after a commit.
 *
 * The post row is the only owner of the delivered bytes. During a protected
 * update the row carries the prior signed title, excerpt, and body for the
 * whole held window, and the candidate lives in this class's state meta until
 * signing commits the exact generation that covers it. Nothing releases until
 * that generation is confirmed, so unsigned candidate bytes are never public.
 *
 * Live-delivery checks match an invariant delivery marker that binds the post
 * id and the committed signing generation on one element. The marker is
 * rendered whenever signed content is served, so hiding the optional reader
 * badge cannot produce a delivery failure.
 *
 * Durable evidence lives in {@see PublicationEventLog}, not post meta, so it
 * survives post deletion, is not capped, and is paged and hash-chained.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

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

final class PublicationAssurance
{
    private const STATE_META = '_encypher_publication_assurance';
    private const LIVE_HOOK = 'encypher_live_delivery_verify';
    private const MAX_LIVE_ATTEMPTS = 4;

    /** One option row per (post, publication generation) decides the outcome. */
    private const OUTCOME_OPTION_PREFIX = 'encypher_publication_outcome_';

    public const OUTCOME_COMMITTED = 'committed';
    public const OUTCOME_CANCELLED = 'cancelled';

    /** Receipt access policy states. */
    public const RECEIPT_PUBLIC = 'public';
    public const RECEIPT_OPERATOR_ONLY = 'operator_only';
    public const RECEIPT_UNAVAILABLE = 'unavailable';

    /** States whose publication is held and whose commit is still pending. */
    private const HELD_STATES = ['preparing', 'preparing_update'];

    /** States in which the candidate is not yet the public generation. */
    private const PENDING_STATES = ['preparing', 'preparing_update', 'signed_scheduled'];

    /** States an operator may cancel. */
    private const CANCELLABLE_STATES = ['preparing', 'preparing_update', 'signing_failed'];

    private Rest $rest;

    /** @var array<int,array<string,mixed>> */
    private static array $pending = [];
    private static bool $releasing = false;
    private static bool $rest_bypass = false;

    public function __construct(Rest $rest)
    {
        $this->rest = $rest;
    }

    public function register_hooks(): void
    {
        add_filter('wp_insert_post_data', [$this, 'protect_publication'], 8, 4);
        add_action('wp_after_insert_post', [$this, 'after_insert'], 20, 4);
        add_action('transition_post_status', [$this, 'after_status_transition'], 30, 3);
        add_action('encypher_signing_completed', [$this, 'release_after_signing'], 10, 2);
        add_action('encypher_signing_failed', [$this, 'record_signing_failure'], 10, 2);
        add_action(self::LIVE_HOOK, [$this, 'verify_live_delivery'], 10, 4);
        // No `the_content` or `the_title` filter: `protect_publication` writes
        // the prior signed title, excerpt, and body into the post row itself,
        // so the row is the last signed value for the whole held window and is
        // the single owner of the delivered bytes. A second copy served through
        // a content filter could only ever disagree with it, and did: a nested
        // reusable-block, embed, or secondary-loop pass reaches `the_content`
        // with the outer article still set as the global post, so the outer
        // held body was served as the inner content.
        add_filter('display_post_states', [$this, 'display_post_state'], 10, 2);
        add_filter('wp_prepare_revision_for_js', [$this, 'prepare_revision_for_js'], 10, 3);
        add_action('wp_restore_post_revision', [$this, 'after_revision_restore'], 10, 2);
        add_action('add_meta_boxes', [$this, 'register_emergency_metabox']);
        add_action('init', [$this, 'register_routes']);
        add_filter('query_vars', [$this, 'query_vars']);
        add_action('template_redirect', [$this, 'render_receipt']);
        add_filter('rest_pre_insert_post', [$this, 'capture_rest_bypass'], 10, 2);
        add_filter('rest_pre_insert_page', [$this, 'capture_rest_bypass'], 10, 2);
    }

    public static function is_releasing(): bool
    {
        return self::$releasing;
    }

    /**
     * The publication generation currently recorded for a post, or ''.
     *
     * One post-meta read, cheap enough for a front-end render that wants to
     * stamp the delivered document with the generation it is serving.
     */
    public static function publication_generation(int $post_id): string
    {
        if ($post_id <= 0) {
            return '';
        }
        $state = get_post_meta($post_id, self::STATE_META, true);
        return is_array($state) ? (string) ($state['publication_generation'] ?? '') : '';
    }

    /**
     * Reopen a failed publication for another signing attempt.
     *
     * The failed attempt's generation is replaced, so its outcome row can never
     * be claimed again and a late response from the failed run cannot publish.
     */
    public static function prepare_retry(int $post_id): void
    {
        $state = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($state) || 'signing_failed' !== (string) ($state['state'] ?? '')) {
            return;
        }

        $previous = (string) ($state['publication_generation'] ?? '');
        $generation = self::mint_generation($post_id);
        $state['state'] = ! empty($state['was_public']) ? 'preparing_update' : 'preparing';
        $state['publication_generation'] = $generation;
        unset($state['error_code'], $state['error_message'], $state['failed_at']);
        update_post_meta($post_id, self::STATE_META, $state);

        self::discard_outcome($post_id, $previous);
        self::append_event($post_id, 'signing_retry_requested', [
            'event_id' => 'retry:' . $post_id . ':' . $generation,
            'generation' => $generation,
            'requested_status' => (string) ($state['requested_status'] ?? ''),
            'payload' => ['previous_generation' => $previous],
        ]);
    }

    /**
     * Replace the assurance row only when its full serialized value is still
     * the value the caller observed.
     *
     * WordPress serializes both array arguments before applying the
     * `meta_value = previous_value` predicate. The full array, rather than only
     * `publication_generation`, is the compare-and-swap token, so a concurrent
     * hold or cancellation within the same generation also wins.
     *
     * @param array<string,mixed> $expected
     * @param array<string,mixed> $replacement
     */
    private static function compare_and_swap_state(int $post_id, array $expected, array $replacement): bool
    {
        $current = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($current) || $current !== $expected) {
            return false;
        }
        if (! update_post_meta($post_id, self::STATE_META, $replacement, $expected)) {
            return false;
        }
        return $replacement === get_post_meta($post_id, self::STATE_META, true);
    }

    // -----------------------------------------------------------------------
    // Publication outcome: one compare-and-swap boundary
    // -----------------------------------------------------------------------

    /**
     * Mint a publication generation. Distinct from the signing generation: this
     * one identifies the publication attempt whose commit or cancellation races.
     */
    private static function mint_generation(int $post_id): string
    {
        return substr(hash('sha256', $post_id . '|' . microtime(true) . '|' . uniqid('', true)), 0, 32);
    }

    private static function outcome_option_name(int $post_id, string $generation): string
    {
        return self::OUTCOME_OPTION_PREFIX . $post_id . '_'
            . (string) preg_replace('/[^A-Za-z0-9\-]/', '', $generation);
    }

    /**
     * The settled outcome for one publication generation, or '' when open.
     */
    public static function publication_outcome(int $post_id, string $generation): string
    {
        if ($post_id <= 0 || '' === $generation) {
            return '';
        }
        $decoded = self::decode_outcome(self::read_outcome(self::outcome_option_name($post_id, $generation)));
        return (string) ($decoded['outcome'] ?? '');
    }

    /**
     * Claim the terminal outcome for one publication generation.
     *
     * The option row's UNIQUE `option_name` is the compare-and-swap: the first
     * insert wins and `INSERT IGNORE` never overwrites the winner. The returned
     * value is the winner as stored, which is the caller's own outcome only when
     * the caller won.
     *
     * @return string 'committed', 'cancelled', or '' when no outcome could be stored.
     */
    private static function claim_publication_outcome(int $post_id, string $generation, string $outcome): string
    {
        global $wpdb;

        $key = self::outcome_option_name($post_id, $generation);
        $payload = self::encode_outcome([
            'outcome' => $outcome,
            'generation' => $generation,
            'actor_id' => (int) get_current_user_id(),
            'at' => gmdate('c'),
        ]);

        if (self::options_table_available()) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WordPress exposes no atomic option insert; the unique option_name index is the compare-and-swap and the winner must be read uncached.
            $wpdb->query($wpdb->prepare(
                'INSERT IGNORE INTO `' . $wpdb->options . '` (option_name, option_value, autoload) VALUES (%s, %s, %s)',
                $key,
                $payload,
                'no'
            ));
            if (function_exists('wp_cache_delete')) {
                wp_cache_delete($key, 'options');
                wp_cache_delete('notoptions', 'options');
            }
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $decoded = self::decode_outcome(self::read_outcome($key));
            return (string) ($decoded['outcome'] ?? '');
        }

        if (function_exists('add_option') && add_option($key, $payload, '', false)) {
            return $outcome;
        }
        $decoded = self::decode_outcome(self::read_outcome($key));
        return (string) ($decoded['outcome'] ?? '');
    }

    /**
     * Drop a superseded generation's outcome row. Only ever called for a
     * generation the caller has already replaced, never for an open one.
     */
    private static function discard_outcome(int $post_id, string $generation): void
    {
        if ('' === $generation || ! function_exists('delete_option')) {
            return;
        }
        delete_option(self::outcome_option_name($post_id, $generation));
    }

    private static function options_table_available(): bool
    {
        global $wpdb;
        return is_object($wpdb)
            && isset($wpdb->options)
            && is_string($wpdb->options)
            && '' !== $wpdb->options
            && is_callable([$wpdb, 'query'])
            && is_callable([$wpdb, 'prepare'])
            && is_callable([$wpdb, 'get_var']);
    }

    private static function read_outcome(string $key): string
    {
        global $wpdb;

        if (self::options_table_available()) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- the outcome winner must be read past the option cache.
            $stored = $wpdb->get_var($wpdb->prepare(
                'SELECT option_value FROM `' . $wpdb->options . '` WHERE option_name = %s LIMIT 1',
                $key
            ));
            return is_string($stored) ? $stored : '';
        }

        $stored = function_exists('get_option') ? get_option($key, '') : '';
        return is_string($stored) ? $stored : '';
    }

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

    /**
     * @return array<string,mixed>
     */
    private static function decode_outcome(string $raw): array
    {
        if ('' === $raw) {
            return [];
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : [];
    }

    // -----------------------------------------------------------------------
    // Evidence
    // -----------------------------------------------------------------------

    /**
     * Append one publication event. Callers own the stable event id so a
     * replayed hook or cron tick records the fact once.
     *
     * @param array<string,mixed> $event
     */
    private static function append_event(int $post_id, string $type, array $event): void
    {
        if (! class_exists(PublicationEventLog::class)) {
            return;
        }
        // Import any legacy post-meta history first so the imported facts keep
        // their place ahead of this new one in the ledger.
        PublicationEventLog::ensure_migrated($post_id);

        if (! array_key_exists('actor_id', $event)) {
            $event['actor_id'] = (int) get_current_user_id();
        }
        $appended = PublicationEventLog::append(array_merge($event, [
            'post_id' => $post_id,
            'event_type' => $type,
        ]));
        if (is_wp_error($appended) && function_exists('do_action')) {
            do_action('encypher_publication_evidence_unavailable', $post_id, $appended);
        }
    }

    // -----------------------------------------------------------------------
    // Holding a publication
    // -----------------------------------------------------------------------

    /**
     * @param array<string,mixed> $data
     * @param array<string,mixed> $postarr
     * @return array<string,mixed>
     */
    public function protect_publication(array $data, array $postarr, array $unsanitized_postarr = [], bool $update = false): array
    {
        if (self::$releasing || ! in_array((string) ($data['post_status'] ?? ''), ['publish', 'future'], true)) {
            return $data;
        }

        $post_id = (int) ($postarr['ID'] ?? 0);
        $previous = $post_id > 0 ? get_post($post_id) : null;
        if (! $this->policy_applies($data, $post_id)) {
            return $data;
        }

        if ($this->emergency_bypass_requested($post_id, $data)) {
            self::$pending[$post_id] = [
                'bypass' => true,
                'requested_status' => (string) $data['post_status'],
                'reason' => $this->emergency_reason(),
            ];
            return $data;
        }

        $requested_status = (string) $data['post_status'];
        $already_signed = $post_id > 0 && RslExport::has_current_committed_text_provenance($post_id);
        $pending_generation = $post_id > 0
            ? (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true)
            : '';
        $assurance = $post_id > 0 ? get_post_meta($post_id, self::STATE_META, true) : null;
        $publication_is_held = is_array($assurance)
            && in_array((string) ($assurance['state'] ?? ''), self::PENDING_STATES, true);
        if (
            ('' !== $pending_generation || $publication_is_held)
            && $previous
            && $this->is_gutenberg_meta_box_loader_request()
        ) {
            // The editor's legacy meta-box request can arrive after the REST
            // save has held a candidate but before the worker has claimed a
            // generation, or while rich signing is projecting signed output.
            // Keep the worker's current fields and held status; the signing
            // completion hook owns publication.
            $data['post_status'] = (string) $previous->post_status;
            $data['post_title'] = (string) $previous->post_title;
            $data['post_excerpt'] = (string) $previous->post_excerpt;
            $data['post_content'] = (string) $previous->post_content;
            return $data;
        }
        if ('publish' === $requested_status && $previous && 'future' === (string) $previous->post_status && $already_signed) {
            return $data;
        }
        if (
            $already_signed
            && $previous
            && 'publish' === (string) $previous->post_status
            && $this->is_gutenberg_meta_box_loader_request()
        ) {
            // Gutenberg's follow-up meta-box loader carries the unsigned editor
            // body that preceded the REST save. The first request may already
            // have replaced that body with signed text. Preserve the committed
            // document fields while allowing WordPress to process the meta boxes.
            $data['post_title'] = (string) $previous->post_title;
            $data['post_excerpt'] = (string) $previous->post_excerpt;
            $data['post_content'] = (string) $previous->post_content;
            return $data;
        }

        if (
            $already_signed
            && $previous
            && 'publish' === (string) $previous->post_status
            && (string) ($data['post_title'] ?? $previous->post_title) === (string) $previous->post_title
            && (string) ($data['post_excerpt'] ?? $previous->post_excerpt) === (string) $previous->post_excerpt
            && (string) ($data['post_content'] ?? $previous->post_content) === (string) $previous->post_content
        ) {
            // Gutenberg can submit the same publish payload twice: once through
            // the posts REST endpoint and once through its legacy meta-box
            // loader. The latter may arrive after the first request has signed
            // and released the post. Treat that stale replay as a no-op rather
            // than creating a second c2pa.edited signing request.
            return $data;
        }

        $entry = [
            'requested_status' => $requested_status,
            'requested_at' => gmdate('c'),
            'actor_id' => (int) get_current_user_id(),
            'was_public' => $previous && 'publish' === (string) $previous->post_status,
            'prior_status' => $previous ? (string) $previous->post_status : '',
            // The generation that is committed right now. A release must prove
            // signing produced a different one, or the candidate is not signed.
            'held_instance_id' => $post_id > 0
                ? (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true)
                : '',
        ];
        if (! empty($entry['was_public']) && $previous) {
            $entry['candidate_title'] = (string) ($data['post_title'] ?? $previous->post_title);
            $entry['candidate_excerpt'] = (string) ($data['post_excerpt'] ?? $previous->post_excerpt);
            $entry['candidate_content'] = (string) ($data['post_content'] ?? $previous->post_content);
            $entry['held_title'] = (string) $previous->post_title;
            $entry['held_excerpt'] = (string) $previous->post_excerpt;
            // The post row is the exact signed HTML readers currently receive.
            // `_encypher_signed_text` is the signer's plain-text transport and
            // must never replace that HTML while the edited candidate is held.
            $entry['held_content'] = (string) $previous->post_content;
            update_post_meta($post_id, self::STATE_META, array_merge($entry, ['state' => 'preparing_update']));
            $data['post_content'] = $entry['held_content'];
            $data['post_title'] = $entry['held_title'];
            $data['post_excerpt'] = $entry['held_excerpt'];
        } else {
            $data['post_status'] = 'pending';
            $entry['held_as'] = 'pending';
        }

        self::$pending[$post_id] = $entry;
        return $data;
    }

    private function is_gutenberg_meta_box_loader_request(): bool
    {
        return isset($_GET['meta-box-loader']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request classification only.
            && '1' === sanitize_text_field(wp_unslash((string) $_GET['meta-box-loader'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request classification only.
    }

    /**
     * Protected publishing is the default. Fail-open exists only as an explicit
     * operator decision, so a missing option, an option that is not an array,
     * or an absent key all keep publication protected.
     */
    public static function protected_publish_enabled(): bool
    {
        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            return true;
        }

        return ! (
            array_key_exists('protected_publish_enabled', $settings)
            && empty($settings['protected_publish_enabled'])
        );
    }

    /**
     * True only when the operator has explicitly turned protection off.
     */
    public static function fail_open(): bool
    {
        return ! self::protected_publish_enabled();
    }

    /**
     * Whether the protected-publish policy covers this post right now.
     */
    public static function applies_to_post(int $post_id): bool
    {
        $post_type = $post_id > 0 ? (string) get_post_type($post_id) : 'post';

        return self::policy_applies_to(['post_type' => $post_type], $post_id);
    }

    /**
     * @param array<string,mixed> $data
     */
    private function policy_applies(array $data, int $post_id): bool
    {
        return self::policy_applies_to($data, $post_id);
    }

    /**
     * @param array<string,mixed> $data
     */
    private static function policy_applies_to(array $data, int $post_id): bool
    {
        if (! self::protected_publish_enabled()) {
            return false;
        }
        $settings = get_option('encypher_provenance_settings', []);
        $settings = is_array($settings) ? $settings : [];
        // Plugin activation alone must not alter WordPress publication
        // semantics. Protected publishing starts only after a connection has
        // delivered a site or workspace credential.
        if ('' === trim((string) ($settings['api_key'] ?? ''))) {
            return false;
        }

        $post_type = sanitize_key((string) ($data['post_type'] ?? ($post_id > 0 ? get_post_type($post_id) : 'post')));
        $types = isset($settings['post_types']) && is_array($settings['post_types'])
            ? array_map('sanitize_key', $settings['post_types'])
            : ['post', 'page'];
        if (! in_array($post_type, $types, true)) {
            return false;
        }

        $roles = isset($settings['protected_publish_roles']) && is_array($settings['protected_publish_roles'])
            ? array_values(array_filter(array_map('sanitize_key', $settings['protected_publish_roles'])))
            : [];
        if (! empty($roles) && get_current_user_id() > 0) {
            $user = wp_get_current_user();
            if (empty(array_intersect($roles, (array) $user->roles))) {
                return false;
            }
        }

        $configured_terms = isset($settings['protected_publish_terms']) && is_array($settings['protected_publish_terms'])
            ? $settings['protected_publish_terms']
            : [];
        if (! empty($configured_terms) && $post_id > 0) {
            $matched = false;
            foreach ($configured_terms as $taxonomy => $slugs) {
                if (! taxonomy_exists((string) $taxonomy) || ! is_array($slugs)) {
                    continue;
                }
                if (has_term(array_map('sanitize_title', $slugs), (string) $taxonomy, $post_id)) {
                    $matched = true;
                    break;
                }
            }
            if (! $matched) {
                return false;
            }
        }

        return (bool) apply_filters('encypher_protected_publish_applies', true, $post_id, $data, $settings);
    }

    private function emergency_bypass_requested(int $post_id, array $data): bool
    {
        $allowed = current_user_can('manage_options') && current_user_can('publish_posts');
        $form_requested = false;
        if (isset($_POST['encypher_emergency_unsigned'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified below before use.
            $nonce = isset($_POST['encypher_emergency_nonce']) // phpcs:ignore WordPress.Security.NonceVerification.Missing
                ? sanitize_text_field(wp_unslash((string) $_POST['encypher_emergency_nonce'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing
                : '';
            $reason = isset($_POST['encypher_emergency_reason']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- paired with the nonce below.
                ? sanitize_text_field(wp_unslash((string) $_POST['encypher_emergency_reason'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing
                : '';
            $form_requested = '' !== $reason && wp_verify_nonce($nonce, 'encypher_emergency_publish_' . $post_id);
        }
        $requested = self::$rest_bypass || $form_requested;
        $requested = (bool) apply_filters('encypher_protected_publish_bypass', $requested, $post_id, $data);
        return $requested && $allowed;
    }

    private function emergency_reason(): string
    {
        if (isset($_POST['encypher_emergency_reason'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- paired emergency nonce is checked before this value is used.
            return sanitize_text_field(wp_unslash((string) $_POST['encypher_emergency_reason'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing
        }
        return self::$rest_bypass ? 'Authorized API emergency publication' : 'Authorized emergency publication';
    }

    public function capture_rest_bypass($prepared_post, WP_REST_Request $request)
    {
        if ($request->get_param('encypher_emergency_unsigned')) {
            self::$rest_bypass = true;
        }
        return $prepared_post;
    }

    public function after_insert(int $post_id, $post, bool $update, $post_before): void
    {
        if (self::$releasing || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
            return;
        }

        $pending = self::$pending[$post_id] ?? null;
        if (null === $pending && ! empty(self::$pending[0])) {
            $pending = self::$pending[0];
            unset(self::$pending[0]);
        }
        self::$rest_bypass = false;
        if (! is_array($pending)) {
            return;
        }
        unset(self::$pending[$post_id]);

        $generation = self::mint_generation($post_id);
        $previous_state = get_post_meta($post_id, self::STATE_META, true);
        if (is_array($previous_state)) {
            self::discard_outcome($post_id, (string) ($previous_state['publication_generation'] ?? ''));
        }

        if (! empty($pending['bypass'])) {
            $reason = (string) ($pending['reason'] ?? '');
            update_post_meta($post_id, self::STATE_META, [
                'state' => 'emergency_unsigned',
                'publication_generation' => $generation,
                'requested_status' => (string) ($pending['requested_status'] ?? 'publish'),
                'requested_at' => gmdate('c'),
                'actor_id' => get_current_user_id(),
                'reason' => $reason,
            ]);
            self::append_event($post_id, 'emergency_unsigned_publish', [
                'event_id' => 'emergency:' . $post_id . ':' . $generation,
                'generation' => $generation,
                'requested_status' => (string) ($pending['requested_status'] ?? 'publish'),
                'reason_code' => 'authorized_emergency_publication',
                'reason_text' => $reason,
            ]);
            return;
        }

        $pending['state'] = ! empty($pending['was_public']) ? 'preparing_update' : 'preparing';
        $pending['publication_generation'] = $generation;
        update_post_meta($post_id, self::STATE_META, $pending);
        update_post_meta($post_id, '_encypher_protected_policy_applies', 1);
        self::append_event($post_id, 'publication_held', [
            'event_id' => 'hold:' . $post_id . ':' . $generation,
            'generation' => $generation,
            'requested_status' => (string) ($pending['requested_status'] ?? 'publish'),
            'actor_id' => (int) ($pending['actor_id'] ?? 0),
            'reason_code' => ! empty($pending['was_public']) ? 'protected_update' : 'protected_publication',
            'payload' => ['was_public' => ! empty($pending['was_public'])],
        ]);
        do_action('encypher_publication_held', $post_id, $pending);
        $this->rest->request_signing($post_id, ! empty($pending['was_public']));
    }

    public function after_status_transition(string $new_status, string $old_status, $post): void
    {
        if ('publish' !== $new_status || 'publish' === $old_status || ! is_object($post)) {
            return;
        }
        $post_id = (int) ($post->ID ?? 0);
        if ($post_id <= 0 || ! RslExport::has_current_committed_text_provenance($post_id)) {
            return;
        }
        $state = get_post_meta($post_id, self::STATE_META, true);
        if (is_array($state) && 'signed_scheduled' === ($state['state'] ?? '')) {
            $released = $state;
            $released['state'] = 'published_pending_live_verification';
            $released['published_at'] = gmdate('c');
            if (self::compare_and_swap_state($post_id, $state, $released)) {
                $this->schedule_live_verification(
                    $post_id,
                    1,
                    (string) ($released['publication_generation'] ?? ''),
                    (string) ($released['committed_instance_id'] ?? '')
                );
            }
        }
    }

    // -----------------------------------------------------------------------
    // Terminal commit
    // -----------------------------------------------------------------------

    /**
     * @param array<string,mixed> $response_data
     */
    public function release_after_signing(int $post_id, array $response_data = []): void
    {
        $state = get_post_meta($post_id, self::STATE_META, true);
        $state = is_array($state) ? $state : [];
        $current_state = (string) ($state['state'] ?? '');
        if (in_array($current_state, ['cancelled_restored_public', 'cancelled_restored_draft'], true)) {
            // The operator cancelled this publication. A signing worker that
            // finished afterwards owns no publication outcome: it must not gain
            // a receipt, and anything it projected onto the row goes back.
            $reasserted = self::reassert_cancelled_bytes($post_id, $state);
            self::append_event($post_id, 'publication_commit_superseded', [
                'event_id' => 'commitsup-late:' . $post_id . ':'
                    . (string) ($state['publication_generation'] ?? '') . ':'
                    . (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true),
                'generation' => (string) ($state['publication_generation'] ?? ''),
                'requested_status' => (string) ($state['requested_status'] ?? ''),
                'observed_status' => (string) get_post_status($post_id),
                'reason_code' => 'cancelled_before_commit',
                'payload' => ['restored_after_late_projection' => $reasserted],
            ]);
            return;
        }
        if (! in_array($current_state, self::HELD_STATES, true)) {
            $generation = (string) ($state['publication_generation'] ?? '');
            $committed_instance = (string) ($state['committed_instance_id'] ?? '');
            if ('' !== $generation || '' !== $committed_instance) {
                $reported = (string) ($response_data['instance_id'] ?? '');
                if (
                    '' === $generation
                    || '' === $committed_instance
                    || '' === $reported
                    || ! hash_equals($committed_instance, $reported)
                    || ! hash_equals(
                        $committed_instance,
                        (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true)
                    )
                ) {
                    return;
                }
                $this->record_receipt($post_id, $response_data, $generation, $committed_instance);
                $this->link_latest_revision($post_id);
                return;
            }
            $this->record_receipt($post_id, $response_data);
            $this->link_latest_revision($post_id);
            return;
        }

        $generation = (string) ($state['publication_generation'] ?? '');
        if ('' === $generation) {
            // A release with no publication identity cannot be compared with a
            // cancellation or a newer hold. Fail closed rather than minting an
            // identity after signing has already finished.
            return;
        }

        $confirmed = $this->confirm_committed_generation($post_id, $state, $response_data);
        if ('confirmed' !== $confirmed['result']) {
            if ('superseded' === $confirmed['result']) {
                // Another signing generation owns the post. Publish nothing and
                // leave the held state for the generation that does own it.
                self::append_event($post_id, 'publication_commit_superseded', [
                    'event_id' => 'commitsup:' . $post_id . ':' . $generation . ':' . $confirmed['reported'],
                    'generation' => $generation,
                    'instance_id' => $confirmed['reported'],
                    'requested_status' => (string) ($state['requested_status'] ?? ''),
                    'reason_code' => 'signing_generation_superseded',
                ]);
                return;
            }
            // The exact generation covering the held candidate is not committed.
            // Keep the candidate held and expose operator recovery.
            $this->record_signing_failure($post_id, new WP_Error($confirmed['code'], $confirmed['message']));
            return;
        }

        $winner = self::claim_publication_outcome($post_id, $generation, self::OUTCOME_COMMITTED);
        if (self::OUTCOME_COMMITTED !== $winner) {
            // A cancellation owns this generation. Publish nothing of the
            // candidate, and put back anything a still-running signing worker
            // projected onto the row after the cancellation restored it.
            $reasserted = self::reassert_cancelled_bytes($post_id, $state);
            self::append_event($post_id, 'publication_commit_superseded', [
                'event_id' => 'commitsup:' . $post_id . ':' . $generation,
                'generation' => $generation,
                'requested_status' => (string) ($state['requested_status'] ?? ''),
                'reason_code' => '' === $winner ? 'outcome_storage_unavailable' : 'cancelled_before_commit',
                'payload' => ['restored_after_late_projection' => $reasserted],
            ]);
            return;
        }

        $target = (string) ($state['requested_status'] ?? 'publish');
        $committed_instance = (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true);
        $released_state = $state;
        $released_state['state'] = 'future' === $target
            ? 'signed_scheduled'
            : 'published_pending_live_verification';
        $released_state['signed_at'] = gmdate('c');
        $released_state['committed_instance_id'] = $committed_instance;
        unset(
            $released_state['held_content'],
            $released_state['held_title'],
            $released_state['held_excerpt'],
            $released_state['candidate_content'],
            $released_state['candidate_title'],
            $released_state['candidate_excerpt'],
            $released_state['error_code'],
            $released_state['error_message'],
            $released_state['failed_at']
        );

        // Claim the exact serialized hold before touching the post row. The
        // state remains held while WordPress runs update hooks; the claim only
        // prevents this worker from confusing a replacement hold with its own.
        $claimed_state = $state;
        $claimed_state['release_claim'] = hash('sha256', $generation . '|' . $committed_instance);
        if (! self::compare_and_swap_state($post_id, $state, $claimed_state)) {
            return;
        }

        self::$releasing = true;
        try {
            $release = ['ID' => $post_id, 'post_status' => $target];
            // Signing has already projected the candidate's signed HTML onto
            // the post row. Release only the editorial fields that were held;
            // replaying candidate_content here would publish the unsigned input
            // over the signed projection.
            foreach (['title', 'excerpt'] as $field) {
                $key = 'candidate_' . $field;
                if (array_key_exists($key, $state)) {
                    $release['post_' . $field] = (string) $state[$key];
                }
            }
            if (get_post_status($post_id) !== $target || count($release) > 2) {
                wp_update_post($release);
            }
        } finally {
            self::$releasing = false;
        }

        // A hold can begin while WordPress runs update hooks. Never write the
        // old release state over it. If the row update crossed that hold, put
        // back the exact public bytes the newer generation captured.
        if ($claimed_state !== get_post_meta($post_id, self::STATE_META, true)) {
            self::reassert_held_bytes($post_id);
            return;
        }
        if (! self::compare_and_swap_state($post_id, $claimed_state, $released_state)) {
            self::reassert_held_bytes($post_id);
            return;
        }

        $state = $released_state;
        if (! empty($state['was_public']) && function_exists('wp_save_post_revision')) {
            wp_save_post_revision($post_id);
        }
        $this->record_receipt($post_id, $response_data, $generation, $committed_instance);
        $this->link_latest_revision($post_id);
        self::append_event($post_id, 'publication_released', [
            'event_id' => 'release:' . $post_id . ':' . $generation,
            'generation' => $generation,
            'requested_status' => $target,
            'observed_status' => (string) get_post_status($post_id),
            'instance_id' => $committed_instance,
            'document_id' => (string) ($response_data['document_id'] ?? get_post_meta($post_id, '_encypher_provenance_document_id', true)),
            'payload' => ['canonical_input_digest' => (string) ($response_data['canonical_input_digest'] ?? '')],
        ]);
        do_action('encypher_publication_released', $post_id, $state);
        if ('publish' === $target) {
            $this->schedule_live_verification($post_id, 1, $generation, $committed_instance);
        }
    }

    /**
     * Prove that the signing run that just finished committed the exact
     * generation covering the held candidate.
     *
     * The completion must belong to the post's new committed generation and a
     * committed text provenance must exist. An update must either replace the
     * held body or be an exact title/excerpt-only candidate whose unchanged
     * body was covered by the newly reported generation.
     *
     * @param array<string,mixed> $state
     * @param array<string,mixed> $response_data
     * @return array{result:string,code:string,message:string,reported:string}
     */
    private function confirm_committed_generation(int $post_id, array $state, array $response_data): array
    {
        $instance = (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true);
        $reported = (string) ($response_data['instance_id'] ?? '');

        if ('' !== $reported && '' !== $instance && ! hash_equals($instance, $reported)) {
            return [
                'result' => 'superseded',
                'code' => 'publication_generation_superseded',
                'message' => '',
                'reported' => $reported,
            ];
        }

        if ('' === $instance || ! RslExport::has_current_committed_text_provenance($post_id)) {
            return [
                'result' => 'unconfirmed',
                'code' => 'publication_generation_uncommitted',
                'message' => __('Signing finished without committing a provenance generation, so the publication stays held.', 'encypher-provenance'),
                'reported' => $reported,
            ];
        }

        $held_instance = (string) ($state['held_instance_id'] ?? '');
        if ('' !== $held_instance && hash_equals($held_instance, $instance)) {
            return [
                'result' => 'unconfirmed',
                'code' => 'publication_generation_unconfirmed',
                'message' => __('Signing produced no new provenance generation for the held candidate, so the publication stays held.', 'encypher-provenance'),
                'reported' => $reported,
            ];
        }

        if (! empty($state['was_public']) && array_key_exists('held_content', $state)) {
            $post = get_post($post_id);
            $current = is_object($post) ? (string) ($post->post_content ?? '') : '';
            $held_content = (string) $state['held_content'];
            if ($current === $held_content) {
                $content_unchanged = array_key_exists('candidate_content', $state)
                    && hash_equals($held_content, (string) $state['candidate_content']);
                $title_changed = array_key_exists('candidate_title', $state)
                    && (string) $state['candidate_title'] !== (string) ($state['held_title'] ?? '');
                $excerpt_changed = array_key_exists('candidate_excerpt', $state)
                    && (string) $state['candidate_excerpt'] !== (string) ($state['held_excerpt'] ?? '');
                $exact_reported_instance = '' !== $reported && hash_equals($instance, $reported);
                if (! $content_unchanged || (! $title_changed && ! $excerpt_changed) || ! $exact_reported_instance) {
                    return [
                        'result' => 'unconfirmed',
                        'code' => 'publication_candidate_unsigned',
                        'message' => __('The signer did not protect the edited candidate, so the prior signed version stays live.', 'encypher-provenance'),
                        'reported' => $reported,
                    ];
                }
            }
        }

        return ['result' => 'confirmed', 'code' => '', 'message' => '', 'reported' => $reported];
    }

    public function record_signing_failure(int $post_id, WP_Error $error): void
    {
        $state = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($state)) {
            $state = [];
        }
        $generation = (string) ($state['publication_generation'] ?? '');

        if (self::OUTCOME_CANCELLED === self::publication_outcome($post_id, $generation)) {
            // The operator already cancelled and the prior public state is
            // restored. A late failure must not overwrite that.
            self::append_event($post_id, 'signing_failed', [
                'event_id' => 'signfail:' . $post_id . ':' . $generation . ':' . $error->get_error_code(),
                'generation' => $generation,
                'reason_code' => (string) $error->get_error_code(),
                'reason_text' => (string) $error->get_error_message(),
                'payload' => ['suppressed_by' => 'cancellation'],
            ]);
            return;
        }

        if (
            'preparing_update' === (string) ($state['state'] ?? '')
            && ! empty($state['was_public'])
        ) {
            // Restore the prior signed generation's exact bytes. A partial
            // signing run may have projected some of the candidate onto the
            // row, and that candidate is not the public generation.
            $restore = ['ID' => $post_id];
            foreach (['title', 'excerpt', 'content'] as $field) {
                $key = 'held_' . $field;
                if (array_key_exists($key, $state)) {
                    $restore['post_' . $field] = (string) $state[$key];
                }
            }

            self::$releasing = true;
            try {
                if (count($restore) > 1) {
                    wp_update_post($restore);
                }
            } finally {
                self::$releasing = false;
            }
        }

        $state['state'] = 'signing_failed';
        $state['error_code'] = $error->get_error_code();
        $state['error_message'] = $error->get_error_message();
        $state['failed_at'] = gmdate('c');
        update_post_meta($post_id, self::STATE_META, $state);
        self::append_event($post_id, 'signing_failed', [
            'event_id' => 'signfail:' . $post_id . ':' . $generation . ':' . $error->get_error_code(),
            'generation' => $generation,
            'requested_status' => (string) ($state['requested_status'] ?? ''),
            'reason_code' => (string) $error->get_error_code(),
            'reason_text' => (string) $error->get_error_message(),
        ]);
        do_action('encypher_publication_signing_failed', $post_id, $state);
    }

    // -----------------------------------------------------------------------
    // Cancellation
    // -----------------------------------------------------------------------

    /**
     * Cancel a held publication, bound to the generation the caller observed.
     *
     * @return array<string,mixed>|WP_Error
     */
    public static function cancel_publication(int $post_id, string $generation, int $actor_id = 0, string $reason = '')
    {
        $post = $post_id > 0 ? get_post($post_id) : null;
        if (! $post) {
            return new WP_Error(
                'publication_post_missing',
                __('That post no longer exists.', 'encypher-provenance')
            );
        }

        $actor_id = $actor_id > 0 ? $actor_id : (int) get_current_user_id();
        if ($actor_id <= 0 || ! self::actor_may_cancel($actor_id, $post_id)) {
            return new WP_Error(
                'publication_cancel_forbidden',
                __('Cancelling a protected publication requires signing and edit authority for this post.', 'encypher-provenance')
            );
        }

        $state = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($state) || ! in_array((string) ($state['state'] ?? ''), self::CANCELLABLE_STATES, true)) {
            $stored_generation = is_array($state) ? (string) ($state['publication_generation'] ?? '') : '';
            if (
                '' !== $generation
                && '' !== $stored_generation
                && hash_equals($generation, $stored_generation)
                && self::OUTCOME_COMMITTED === self::publication_outcome($post_id, $generation)
            ) {
                return new WP_Error(
                    'publication_already_committed',
                    __('This publication already committed and cannot be rolled back.', 'encypher-provenance')
                );
            }
            return new WP_Error(
                'publication_not_held',
                __('This publication is not held, so there is nothing to cancel.', 'encypher-provenance')
            );
        }

        $current_generation = (string) ($state['publication_generation'] ?? '');
        if ('' === $current_generation || '' === $generation || ! hash_equals($current_generation, $generation)) {
            return new WP_Error(
                'publication_generation_mismatch',
                __('This publication moved on since that screen loaded. Reload and try again.', 'encypher-provenance')
            );
        }

        $winner = self::claim_publication_outcome($post_id, $current_generation, self::OUTCOME_CANCELLED);
        if (self::OUTCOME_CANCELLED !== $winner) {
            self::append_event($post_id, 'publication_cancel_rejected', [
                'event_id' => 'cancelrej:' . $post_id . ':' . $current_generation,
                'generation' => $current_generation,
                'actor_id' => $actor_id,
                'reason_code' => '' === $winner ? 'outcome_storage_unavailable' : 'already_' . $winner,
                'reason_text' => $reason,
            ]);
            return new WP_Error(
                '' === $winner ? 'publication_outcome_unavailable' : 'publication_already_committed',
                '' === $winner
                    ? __('The publication outcome could not be recorded, so nothing was cancelled.', 'encypher-provenance')
                    : __('This publication already committed and cannot be rolled back.', 'encypher-provenance')
            );
        }

        $was_public = ! empty($state['was_public']);
        $restored_status = $was_public ? 'publish' : self::restorable_draft_status($state, $post);
        self::$releasing = true;
        try {
            $restore = ['ID' => $post_id, 'post_status' => $restored_status];
            if ($was_public) {
                foreach (['title', 'excerpt', 'content'] as $field) {
                    $key = 'held_' . $field;
                    if (array_key_exists($key, $state)) {
                        $restore['post_' . $field] = (string) $state[$key];
                    }
                }
            }
            wp_update_post($restore);
        } finally {
            self::$releasing = false;
        }

        $state['state'] = $was_public ? 'cancelled_restored_public' : 'cancelled_restored_draft';
        $state['restored_status'] = $restored_status;
        $state['cancelled_at'] = gmdate('c');
        $state['cancelled_by'] = $actor_id;
        $state['cancel_reason'] = sanitize_text_field($reason);
        if ($was_public) {
            // The exact bytes this cancellation put back. A signing worker that
            // is still running owns no publication outcome, so if it projects
            // its candidate onto the row afterwards, these are what the page
            // must return to.
            foreach (['title', 'excerpt', 'content'] as $field) {
                $key = 'held_' . $field;
                if (array_key_exists($key, $state)) {
                    $state['restored_' . $field] = (string) $state[$key];
                }
            }
        }
        unset(
            $state['held_content'],
            $state['held_title'],
            $state['held_excerpt'],
            $state['error_code'],
            $state['error_message'],
            $state['failed_at']
        );
        update_post_meta($post_id, self::STATE_META, $state);

        self::append_event($post_id, 'publication_cancelled', [
            'event_id' => 'cancel:' . $post_id . ':' . $current_generation,
            'generation' => $current_generation,
            'actor_id' => $actor_id,
            'requested_status' => (string) ($state['requested_status'] ?? ''),
            'observed_status' => $restored_status,
            'reason_code' => $was_public ? 'restored_prior_public' : 'restored_draft',
            'reason_text' => $reason,
        ]);
        do_action('encypher_publication_cancelled', $post_id, $state);

        return [
            'cancelled' => true,
            'generation' => $current_generation,
            'state' => $state['state'],
            'assurance_state' => ContentStatus::assurance_state($state),
            'restored_status' => $restored_status,
        ];
    }
    /**
     * Reassert the public row captured by a newer hold that crossed release
     * hooks. The held state itself is never rewritten.
     */
    private static function reassert_held_bytes(int $post_id): bool
    {
        $state = get_post_meta($post_id, self::STATE_META, true);
        $post = get_post($post_id);
        if (
            ! is_array($state)
            || ! is_object($post)
            || ! in_array((string) ($state['state'] ?? ''), self::HELD_STATES, true)
        ) {
            return false;
        }

        $was_public = ! empty($state['was_public']);
        $status = $was_public ? 'publish' : (string) ($state['held_as'] ?? 'pending');
        if (! in_array($status, ['draft', 'pending', 'private', 'publish'], true)) {
            $status = $was_public ? 'publish' : 'pending';
        }
        $restore = ['ID' => $post_id];
        if ((string) ($post->post_status ?? '') !== $status) {
            $restore['post_status'] = $status;
        }
        if ($was_public) {
            foreach (['title', 'excerpt', 'content'] as $field) {
                $key = 'held_' . $field;
                if (
                    array_key_exists($key, $state)
                    && (string) ($post->{'post_' . $field} ?? '') !== (string) $state[$key]
                ) {
                    $restore['post_' . $field] = (string) $state[$key];
                }
            }
        }
        if (count($restore) < 2) {
            return false;
        }

        self::$releasing = true;
        try {
            wp_update_post($restore);
        } finally {
            self::$releasing = false;
        }
        return true;
    }


    /**
     * Put back the bytes a cancellation restored.
     *
     * Called when a commit loses the outcome claim: the signing worker may have
     * projected its candidate onto the row after the cancellation restored it,
     * and that candidate is not the public generation.
     *
     * @param array<string,mixed> $state
     * @return bool True when the row had drifted and was put back.
     */
    private static function reassert_cancelled_bytes(int $post_id, array $state): bool
    {
        $stored = get_post_meta($post_id, self::STATE_META, true);
        $stored = is_array($stored) ? $stored : $state;
        $post = get_post($post_id);
        if (! is_object($post) || 'cancelled_restored_public' !== (string) ($stored['state'] ?? '')) {
            return false;
        }

        $restore = ['ID' => $post_id];
        foreach (['title', 'excerpt', 'content'] as $field) {
            $key = 'restored_' . $field;
            if (! array_key_exists($key, $stored)) {
                continue;
            }
            if ((string) ($post->{'post_' . $field} ?? '') !== (string) $stored[$key]) {
                $restore['post_' . $field] = (string) $stored[$key];
            }
        }
        if (count($restore) < 2) {
            return false;
        }

        self::$releasing = true;
        try {
            wp_update_post($restore);
        } finally {
            self::$releasing = false;
        }

        return true;
    }

    /**
     * The status a cancelled, never-public publication returns to.
     *
     * `pending` is only honoured when the author chose it: a protected first
     * publish uses `pending` as the hold itself, so restoring to it would leave
     * a cancelled publication sitting in the review queue. `auto-draft` and
     * `future` are never restored: the first would hide the post from every
     * list, the second would republish on schedule.
     *
     * @param array<string,mixed> $state
     * @param object $post
     */
    private static function restorable_draft_status(array $state, $post): string
    {
        $prior = (string) ($state['prior_status'] ?? '');
        if (in_array($prior, ['draft', 'pending', 'private'], true)) {
            return $prior;
        }
        $held_as_pending = 'pending' === (string) ($state['held_as'] ?? '');
        $current = (string) ($post->post_status ?? '');
        if ($held_as_pending && 'pending' === $current) {
            return 'draft';
        }

        return in_array($current, ['draft', 'pending', 'private'], true) ? $current : 'draft';
    }

    private static function actor_may_cancel(int $actor_id, int $post_id): bool
    {
        if (! function_exists('user_can')) {
            return true;
        }
        return user_can($actor_id, 'encypher_sign_content')
            && user_can($actor_id, 'edit_post', $post_id);
    }

    // -----------------------------------------------------------------------
    // Live delivery
    // -----------------------------------------------------------------------

    private function schedule_live_verification(
        int $post_id,
        int $attempt,
        string $generation,
        string $instance_id,
        int $delay = 5
    ): void {
        if ('' === $generation || '' === $instance_id) {
            return;
        }
        $args = [$post_id, $attempt, $generation, $instance_id];
        if (! wp_next_scheduled(self::LIVE_HOOK, $args)) {
            wp_schedule_single_event(time() + $delay, self::LIVE_HOOK, $args);
        }
    }

    /**
     * A scheduled worker owns only one released publication and signing pair.
     *
     * @param array<string,mixed> $state
     */
    private static function live_worker_owns_state(
        int $post_id,
        array $state,
        string $generation,
        string $instance_id
    ): bool {
        $allowed = ['published_pending_live_verification', 'public_delivery_failed', 'live_verified'];
        $current_state = get_post_meta($post_id, self::STATE_META, true);
        $current_instance = (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true);
        return '' !== $generation
            && '' !== $instance_id
            && is_array($current_state)
            && $current_state === $state
            && in_array((string) ($state['state'] ?? ''), $allowed, true)
            && hash_equals($generation, (string) ($state['publication_generation'] ?? ''))
            && hash_equals($instance_id, (string) ($state['committed_instance_id'] ?? ''))
            && hash_equals($instance_id, $current_instance);
    }

    /**
     * Write one live-verification terminal value only while the exact
     * assurance state and committed signing instance still own the post.
     *
     * The metadata API cannot bind a write to rows for two other keys. These
     * statements make those predicates part of the storage write itself. The
     * conflicting-row joins fail closed if malformed duplicate post-meta rows
     * disagree about either owner.
     *
     * @param array<string,mixed> $state
     */
    private static function write_live_terminal_meta(
        int $post_id,
        string $meta_key,
        string $meta_value,
        array $state,
        string $generation,
        string $instance_id
    ): bool {
        global $wpdb;

        if (
            '' === $meta_key
            || ! self::live_worker_owns_state($post_id, $state, $generation, $instance_id)
            || ! isset($wpdb->postmeta)
            || ! is_string($wpdb->postmeta)
            || '' === $wpdb->postmeta
            || ! is_callable([$wpdb, 'prepare'])
            || ! is_callable([$wpdb, 'query'])
        ) {
            return false;
        }

        $serialized_state = function_exists('maybe_serialize')
            ? maybe_serialize($state)
            : serialize($state); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- standalone fallback serializes plugin-owned scalar/array assurance state, never objects or request data.
        $state_key = self::STATE_META;
        $instance_key = '_encypher_provenance_instance_id';
        do_action('encypher_live_terminal_meta_checkpoint', $meta_key, $post_id, $generation, $instance_id);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- wpdb owns the table name; the exact assurance state and instance are bound into the same statement as the terminal write.
        $updated = $wpdb->query($wpdb->prepare(
            "UPDATE {$wpdb->postmeta} terminal
             INNER JOIN {$wpdb->postmeta} expected_assurance
                ON expected_assurance.post_id = terminal.post_id
               AND expected_assurance.meta_key = %s
               AND expected_assurance.meta_value = %s
             LEFT JOIN {$wpdb->postmeta} conflicting_assurance
                ON conflicting_assurance.post_id = terminal.post_id
               AND conflicting_assurance.meta_key = %s
               AND conflicting_assurance.meta_value <> %s
             INNER JOIN {$wpdb->postmeta} expected_instance
                ON expected_instance.post_id = terminal.post_id
               AND expected_instance.meta_key = %s
               AND expected_instance.meta_value = %s
             LEFT JOIN {$wpdb->postmeta} conflicting_instance
                ON conflicting_instance.post_id = terminal.post_id
               AND conflicting_instance.meta_key = %s
               AND conflicting_instance.meta_value <> %s
             SET terminal.meta_value = %s
             WHERE terminal.post_id = %d
               AND terminal.meta_key = %s
               AND conflicting_assurance.meta_id IS NULL
               AND conflicting_instance.meta_id IS NULL
             /* encypher-live-terminal-update */",
            $state_key,
            $serialized_state,
            $state_key,
            $serialized_state,
            $instance_key,
            $instance_id,
            $instance_key,
            $instance_id,
            $meta_value,
            $post_id,
            $meta_key
        ));
        if (false === $updated) {
            return false;
        }

        $inserted = 0;
        if (0 === (int) $updated) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- same storage-level ownership boundary as the UPDATE; NOT EXISTS avoids adding another row for an existing key.
            $inserted = $wpdb->query($wpdb->prepare(
                "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
                 SELECT DISTINCT %d, %s, %s
                 FROM {$wpdb->postmeta} expected_assurance
                 LEFT JOIN {$wpdb->postmeta} conflicting_assurance
                    ON conflicting_assurance.post_id = expected_assurance.post_id
                   AND conflicting_assurance.meta_key = %s
                   AND conflicting_assurance.meta_value <> %s
                 INNER JOIN {$wpdb->postmeta} expected_instance
                    ON expected_instance.post_id = expected_assurance.post_id
                   AND expected_instance.meta_key = %s
                   AND expected_instance.meta_value = %s
                 LEFT JOIN {$wpdb->postmeta} conflicting_instance
                    ON conflicting_instance.post_id = expected_assurance.post_id
                   AND conflicting_instance.meta_key = %s
                   AND conflicting_instance.meta_value <> %s
                 WHERE expected_assurance.post_id = %d
                   AND expected_assurance.meta_key = %s
                   AND expected_assurance.meta_value = %s
                   AND conflicting_assurance.meta_id IS NULL
                   AND conflicting_instance.meta_id IS NULL
                   AND NOT EXISTS (
                       SELECT 1 FROM {$wpdb->postmeta} existing
                       WHERE existing.post_id = %d AND existing.meta_key = %s
                   )
                 /* encypher-live-terminal-insert */",
                $post_id,
                $meta_key,
                $meta_value,
                $state_key,
                $serialized_state,
                $instance_key,
                $instance_id,
                $instance_key,
                $instance_id,
                $post_id,
                $state_key,
                $serialized_state,
                $post_id,
                $meta_key
            ));
            if (false === $inserted) {
                return false;
            }
        }

        if ((int) $updated > 0 || (int) $inserted > 0) {
            if (function_exists('wp_cache_delete')) {
                wp_cache_delete($post_id, 'post_meta');
            }
            return true;
        }

        return self::live_worker_owns_state($post_id, $state, $generation, $instance_id)
            && hash_equals($meta_value, (string) get_post_meta($post_id, $meta_key, true));
    }

    /**
     * The fetch target must be an HTTP(S) URL on the configured site origin.
     */
    private static function approved_live_url(string $url): bool
    {
        $target = wp_parse_url($url);
        $approved = wp_parse_url((string) home_url('/'));
        if (! is_array($target) || ! is_array($approved)) {
            return false;
        }
        $target_scheme = strtolower((string) ($target['scheme'] ?? ''));
        $approved_scheme = strtolower((string) ($approved['scheme'] ?? ''));
        $target_host = strtolower(rtrim((string) ($target['host'] ?? ''), '.'));
        $approved_host = strtolower(rtrim((string) ($approved['host'] ?? ''), '.'));
        if (
            ! in_array($target_scheme, ['http', 'https'], true)
            || $target_scheme !== $approved_scheme
            || '' === $target_host
            || $target_host !== $approved_host
            || isset($target['user'])
            || isset($target['pass'])
        ) {
            return false;
        }
        $target_port = (int) ($target['port'] ?? ('https' === $target_scheme ? 443 : 80));
        $approved_port = (int) ($approved['port'] ?? ('https' === $approved_scheme ? 443 : 80));
        return $target_port === $approved_port;
    }

    public function verify_live_delivery(
        int $post_id,
        int $attempt = 1,
        string $generation = '',
        string $instance_id = ''
    ): void {
        $observed_state = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($observed_state) || ! self::live_worker_owns_state($post_id, $observed_state, $generation, $instance_id)) {
            return;
        }

        $post = get_post($post_id);
        $url = get_permalink($post_id);
        if (! $post || 'publish' !== (string) $post->post_status || ! is_string($url) || '' === $url) {
            return;
        }

        $error = null;
        if (! self::approved_live_url($url)) {
            $error = new WP_Error('public_delivery_origin_invalid', 'The public URL is not on the approved site origin.');
        } else {
            // The safe transport blocks private-address targets. Redirects are
            // disabled, so an approved permalink cannot bounce the verifier to
            // an attacker-controlled origin or private service.
            $response = wp_safe_remote_get($url, [
                'timeout' => 3,
                'redirection' => 0,
                'headers' => ['Cache-Control' => 'no-cache'],
                'user-agent' => 'Encypher-WordPress-Live-Verification/' . ENCYPHER_PROVENANCE_VERSION,
            ]);
            if (is_wp_error($response)) {
                $error = $response;
            } elseif (200 !== (int) wp_remote_retrieve_response_code($response)) {
                $error = new WP_Error('public_delivery_http', sprintf('The public URL returned HTTP %d.', (int) wp_remote_retrieve_response_code($response)));
            } else {
                $body = (string) wp_remote_retrieve_body($response);
                $error = $this->match_delivered_generation($post_id, $body, $instance_id);
                if (! $error) {
                    foreach (VipMediaDelivery::public_signed_media($post_id) as $media) {
                        $media_url = (string) ($media['url'] ?? '');
                        if ('' !== $media_url && ! str_contains(html_entity_decode($body, ENT_QUOTES, 'UTF-8'), $media_url)) {
                            $error = new WP_Error('public_signed_media_missing', 'A signed media URL was not present in the delivered page.');
                            break;
                        }
                    }
                }
            }
        }

        if (! $error) {
            // The permalink request can take seconds. Do not invoke the
            // verifier, which may write signing status, after ownership moved.
            if (! self::live_worker_owns_state($post_id, $observed_state, $generation, $instance_id)) {
                return;
            }
            $request = new WP_REST_Request('POST', '/encypher-provenance/v1/verify');
            $request->set_param('post_id', $post_id);
            $verified = $this->rest->handle_verify_request($request);
            if (is_wp_error($verified)) {
                $error = $verified;
            } elseif ($verified instanceof WP_REST_Response) {
                $payload = (array) $verified->get_data();
                if (empty($payload['valid']) && empty($payload['verified']) && 'verified' !== ($payload['status'] ?? '')) {
                    $error = new WP_Error('public_integrity_invalid', 'The signing service could not verify the live publication.');
                }
            }
        }

        // The network and verification calls above may outlive their
        // generation. Re-read, validate both immutable identities, and CAS the
        // full state. A newer hold causes an immediate mutation-free exit.
        $observed_state = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($observed_state) || ! self::live_worker_owns_state($post_id, $observed_state, $generation, $instance_id)) {
            return;
        }
        $state = $observed_state;
        $state['state'] = $error ? 'public_delivery_failed' : 'live_verified';
        $state['live_checked_at'] = gmdate('c');
        $state['live_attempt'] = $attempt;
        $state['live_check_kind'] = 'scheduled';
        if ($error) {
            $state['live_error'] = $error->get_error_message();
        } else {
            unset($state['live_error']);
        }
        if (! self::compare_and_swap_state($post_id, $observed_state, $state)) {
            return;
        }

        if ($error) {
            if (! self::live_worker_owns_state($post_id, $state, $generation, $instance_id)) {
                return;
            }
            self::append_event($post_id, 'live_check_failed', [
                'event_id' => 'live-fail:' . $post_id . ':' . $generation . ':' . $attempt . ':' . $error->get_error_code(),
                'generation' => $generation,
                'canonical_url' => (string) $url,
                'observed_status' => (string) $post->post_status,
                'instance_id' => $instance_id,
                'reason_code' => (string) $error->get_error_code(),
                'reason_text' => (string) $error->get_error_message(),
                'payload' => ['attempt' => $attempt],
            ]);
            if ($attempt < self::MAX_LIVE_ATTEMPTS) {
                $this->schedule_live_verification(
                    $post_id,
                    $attempt + 1,
                    $generation,
                    $instance_id,
                    300 * $attempt
                );
            } else {
                ErrorLog::record_failure($post_id, $error->get_error_code(), $error->get_error_message(), 'public_delivery');
                ErrorLog::maybe_fire_webhook($post_id, [
                    'post_title' => get_the_title($post_id),
                    'error_code' => $error->get_error_code(),
                    'error_message' => $error->get_error_message(),
                    'consecutive_failures' => ErrorLog::get_consecutive_failures($post_id),
                    'timestamp' => gmdate('c'),
                    'context' => 'public_delivery',
                ]);
            }
            $this->update_current_receipt_live_state($post_id, $generation, $instance_id, false, $error->get_error_message());
            return;
        }

        if (! self::write_live_terminal_meta(
            $post_id,
            '_encypher_provenance_last_verified',
            current_time('mysql'),
            $state,
            $generation,
            $instance_id
        )) {
            return;
        }
        if (! self::write_live_terminal_meta(
            $post_id,
            '_encypher_provenance_status',
            'verified',
            $state,
            $generation,
            $instance_id
        )) {
            return;
        }
        $this->update_current_receipt_live_state($post_id, $generation, $instance_id, true, '');
        if (! self::live_worker_owns_state($post_id, $state, $generation, $instance_id)) {
            return;
        }
        self::append_event($post_id, 'live_check_verified', [
            'event_id' => 'live-ok:' . $post_id . ':' . $generation . ':' . $attempt . ':' . $instance_id,
            'generation' => $generation,
            'canonical_url' => (string) $url,
            'observed_status' => (string) $post->post_status,
            'instance_id' => $instance_id,
            'reason_code' => 'live_verified',
            'payload' => ['attempt' => $attempt],
        ]);
        do_action('encypher_live_delivery_verified', $post_id, $state);
    }

    /**
     * Match the delivered document against the invariant provenance marker.
     *
     * The marker binds the post id and the committed signing generation on one
     * element, and it is rendered whenever signed content is served. The
     * optional reader badge is accepted as a second marker shape, so an older
     * cached page still verifies, but hiding the badge cannot fail a check.
     *
     * @return WP_Error|null Null when the current generation is delivered.
     */
    private function match_delivered_generation(int $post_id, string $body, string $expected)
    {
        if ('' === $expected) {
            return new WP_Error('public_generation_unknown', 'The worker has no committed provenance generation to match.');
        }

        $markers = self::delivered_markers($body);
        if (is_wp_error($markers)) {
            return $markers;
        }

        $for_this_post = 0;
        foreach ($markers as $marker) {
            if ((string) $post_id !== $marker['post_id']) {
                continue;
            }
            $for_this_post++;
            if ('' !== $marker['generation'] && hash_equals($expected, $marker['generation'])) {
                return null;
            }
        }

        return $for_this_post > 0
            ? new WP_Error('public_generation_stale', 'The public page did not contain the newly signed provenance generation.')
            : new WP_Error('public_provenance_missing', 'The public page did not carry the invariant provenance marker for this post.');
    }

    /**
     * Collect delivery markers from the fetched document.
     *
     * Only real attributes of real elements are read, and the post id and the
     * generation must sit on the same element, so a matching string in text, a
     * comment, or an unrelated node cannot satisfy a check.
     *
     * @return array<int,array{post_id:string,generation:string}>|WP_Error
     */
    private static function delivered_markers(string $body)
    {
        if ('' === trim($body)) {
            return new WP_Error('public_delivery_empty', 'The public URL returned an empty document.');
        }
        if (! class_exists('\DOMDocument')) {
            return new WP_Error('public_delivery_parser_unavailable', 'The PHP DOM extension is required to verify delivered provenance.');
        }

        $document = new \DOMDocument('1.0', 'UTF-8');
        $previous = libxml_use_internal_errors(true);
        $loaded = $document->loadHTML('<?xml encoding="UTF-8">' . $body, LIBXML_NOERROR | LIBXML_NOWARNING);
        libxml_clear_errors();
        libxml_use_internal_errors($previous);
        if (! $loaded) {
            return new WP_Error('public_delivery_unparsable', 'The delivered document could not be parsed as HTML.');
        }

        $markers = [];
        foreach ($document->getElementsByTagName('*') as $element) {
            if (! $element instanceof \DOMElement) {
                continue;
            }
            $post_id = trim($element->getAttribute('data-encypher-live-post'));
            if ('' !== $post_id) {
                $markers[] = [
                    'post_id' => $post_id,
                    'generation' => trim($element->getAttribute('data-encypher-live-signing-generation')),
                ];
                continue;
            }
            $classes = preg_split('/\s+/', (string) $element->getAttribute('class'), -1, PREG_SPLIT_NO_EMPTY);
            if (is_array($classes) && in_array('encypher-c2pa-badge', $classes, true)) {
                $markers[] = [
                    'post_id' => trim($element->getAttribute('data-post-id')),
                    'generation' => trim($element->getAttribute('data-provenance-generation')),
                ];
            }
        }

        return $markers;
    }

    /**
     * @param array<string,string> $states
     * @return array<string,string>
     */
    public function display_post_state(array $states, $post): array
    {
        $state = get_post_meta((int) $post->ID, self::STATE_META, true);
        if (! is_array($state)) {
            return $states;
        }
        $value = (string) ($state['state'] ?? '');
        if (in_array($value, ['preparing', 'preparing_update'], true)) {
            $states['encypher-preparing'] = __('Held for signing', 'encypher-provenance');
        } elseif ('signed_scheduled' === $value) {
            $states['encypher-ready'] = __('Provenance ready', 'encypher-provenance');
        } elseif ('live_verified' === $value) {
            $states['encypher-live'] = __('Live provenance verified', 'encypher-provenance');
        } elseif (in_array($value, ['signing_failed', 'public_delivery_failed'], true)) {
            $states['encypher-issue'] = __('Provenance issue', 'encypher-provenance');
        } elseif (in_array($value, ['cancelled_restored_public', 'cancelled_restored_draft'], true)) {
            $states['encypher-cancelled'] = __('Publication cancelled', 'encypher-provenance');
        } elseif ('emergency_unsigned' === $value) {
            $states['encypher-unsigned'] = __('Published unsigned', 'encypher-provenance');
        }
        return $states;
    }

    // -----------------------------------------------------------------------
    // Operator read model
    // -----------------------------------------------------------------------

    /**
     * The publication-assurance status dimension for one post.
     *
     * Assurance is separate from the signing status, and a delivery failure
     * dominates a stale Signed or Verified label.
     *
     * @return array{state:string,assurance_state:string,severity:string,dominates_label:bool}
     */
    public static function assurance_dimension(int $post_id): array
    {
        $state = get_post_meta($post_id, self::STATE_META, true);
        $current = is_array($state) ? (string) ($state['state'] ?? '') : '';
        $canonical = ContentStatus::assurance_state($current);

        if ('failed' === $canonical) {
            $severity = 'failed';
        } elseif ('cancelled' === $canonical) {
            $severity = 'cancelled';
        } elseif ('emergency_unsigned' === $current) {
            $severity = 'bypassed';
        } elseif ('live_verified' === $canonical) {
            $severity = 'ok';
        } elseif (in_array($canonical, ['held', 'live_unverified'], true)) {
            $severity = 'pending';
        } else {
            $severity = 'none';
        }

        return [
            'state' => $current,
            'assurance_state' => $canonical,
            'severity' => $severity,
            // Only a failed assurance overrides a Signed or Verified label.
            'dominates_label' => 'failed' === $canonical,
        ];
    }

    /**
     * One authoritative projection of publication assurance for a post.
     *
     * @return array<string,mixed>
     */
    public static function projection(int $post_id): array
    {
        $raw = get_post_meta($post_id, self::STATE_META, true);
        $state = is_array($raw) ? $raw : [];
        $current = (string) ($state['state'] ?? '');
        $generation = (string) ($state['publication_generation'] ?? '');
        $post = $post_id > 0 ? get_post($post_id) : null;
        $dimension = self::assurance_dimension($post_id);
        $receipt = class_exists(PublicationEventLog::class)
            ? PublicationEventLog::current_receipt($post_id)
            : null;
        $availability = self::receipt_availability($post_id);
        // The candidate bytes are still held while a commit is pending and
        // while a failed attempt waits for an operator. `assurance_state` says
        // whether that wait needs a human; this says whether bytes are public.
        $held = in_array($current, self::CANCELLABLE_STATES, true);
        $outcome = self::publication_outcome($post_id, $generation);
        $registry = class_exists(PublisherPublication::class)
            ? PublisherPublication::projection($post_id)
            : [];
        $receipt_url = self::receipt_url($post_id);
        $signed = RslExport::has_current_committed_text_provenance($post_id);
        $phase = self::publish_phase($current, $state, $signed);

        return [
            'assurance_state' => $dimension['assurance_state'],
            'internal_state' => $current,
            'severity' => $dimension['severity'],
            'dominates_label' => $dimension['dominates_label'],
            'display_state_override' => $dimension['dominates_label'] ? 'needs_attention' : '',
            'needs_operator_attention' => in_array($dimension['severity'], ['failed', 'bypassed'], true)
                || 'failed' === (string) ($registry['delivery_state'] ?? ''),
            'publish_phase' => $phase['phase'],
            'blocked_reason' => $phase['blocked_reason'],
            'publish_facts' => [
                'protected_publish_enabled' => self::protected_publish_enabled(),
                'applies' => self::applies_to_post($post_id),
                'fail_open' => self::fail_open(),
                'candidate_status' => (string) ($state['requested_status'] ?? ''),
                'held' => $held,
            ],
            'held' => $held,
            'requested_status' => (string) ($state['requested_status'] ?? ''),
            'publication_generation' => $generation,
            'publication_outcome' => $outcome,
            'was_public' => ! empty($state['was_public']),
            'prior_status' => (string) ($state['prior_status'] ?? ''),
            'observed_status' => is_object($post) ? (string) ($post->post_status ?? '') : '',
            'signing_generation' => [
                'committed_instance_id' => (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true),
                'pending' => (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true),
                'held_instance_id' => (string) ($state['held_instance_id'] ?? ''),
            ],
            'candidate' => [
                'title' => (string) ($state['candidate_title'] ?? ''),
                'excerpt' => (string) ($state['candidate_excerpt'] ?? ''),
                'content' => (string) ($state['candidate_content'] ?? ''),
            ],
            'current_live' => [
                'title' => is_object($post) ? (string) ($post->post_title ?? '') : '',
                'excerpt' => is_object($post) ? (string) ($post->post_excerpt ?? '') : '',
                'content' => is_object($post) ? (string) ($post->post_content ?? '') : '',
            ],
            'failure' => [
                'code' => (string) ($state['error_code'] ?? ''),
                'message' => (string) ($state['error_message'] ?? ''),
                'at' => (string) ($state['failed_at'] ?? ''),
            ],
            'live' => [
                'checked_at' => (string) ($state['live_checked_at'] ?? ''),
                'attempt' => (int) ($state['live_attempt'] ?? 0),
                'max_attempts' => self::MAX_LIVE_ATTEMPTS,
                'error' => (string) ($state['live_error'] ?? ''),
                'kind' => (string) ($state['live_check_kind'] ?? ''),
            ],
            'cancellation' => [
                'at' => (string) ($state['cancelled_at'] ?? ''),
                'actor_id' => (int) ($state['cancelled_by'] ?? 0),
                'reason' => (string) ($state['cancel_reason'] ?? ''),
                'restored_status' => (string) ($state['restored_status'] ?? ''),
            ],
            'receipt' => [
                'availability' => $availability,
                'token' => is_array($receipt) ? (string) $receipt['receipt_token'] : '',
                'url' => $receipt_url,
                'json_url' => '' === $receipt_url ? '' : add_query_arg('format', 'json', $receipt_url),
                'integrity' => is_array($receipt) ? (string) $receipt['integrity'] : '',
                'signed_at' => is_array($receipt) ? (string) $receipt['signed_at'] : '',
                'live_verified_at' => is_array($receipt) ? (string) $receipt['live_verified_at'] : '',
                'version' => is_array($receipt) ? (int) $receipt['version'] : 0,
            ],
            'registry' => $registry,
            'chain' => class_exists(PublicationEventLog::class)
                ? PublicationEventLog::chain($post_id)
                : ['available' => false, 'head_sequence' => 0, 'entries' => []],
            'actions' => [
                'can_cancel' => in_array($current, self::CANCELLABLE_STATES, true)
                    && '' !== $generation
                    && '' === $outcome,
                'can_retry' => 'signing_failed' === $current,
                'can_recheck' => is_object($post)
                    && 'publish' === (string) ($post->post_status ?? '')
                    && $signed,
                'can_retry_registry' => (bool) ($registry['retryable'] ?? false),
            ],
            'evidence_available' => class_exists(PublicationEventLog::class) && PublicationEventLog::available(),
        ];
    }

    /**
     * The author-facing publish phase for the publish panel.
     *
     * @param array<string,mixed> $state
     * @return array{phase:string,blocked_reason:string}
     */
    private static function publish_phase(string $current, array $state, bool $signed): array
    {
        if (in_array($current, ['signing_failed', 'public_delivery_failed'], true)) {
            $message = (string) ($state['error_message'] ?? '');
            if ('' === $message) {
                $message = (string) ($state['live_error'] ?? '');
            }
            return ['phase' => 'blocked', 'blocked_reason' => $message];
        }
        if (in_array($current, self::HELD_STATES, true)) {
            return ['phase' => 'held_for_signing', 'blocked_reason' => ''];
        }
        if ('signed_scheduled' === $current) {
            return ['phase' => 'held_for_signing', 'blocked_reason' => ''];
        }
        if (in_array($current, ['published_pending_live_verification', 'live_verified'], true) && $signed) {
            return ['phase' => 'signed_and_live', 'blocked_reason' => ''];
        }
        return ['phase' => 'will_sign', 'blocked_reason' => ''];
    }

    // -----------------------------------------------------------------------
    // Emergency publication control
    // -----------------------------------------------------------------------

    public function register_emergency_metabox(): void
    {
        if (! current_user_can('manage_options') || ! current_user_can('publish_posts')) {
            return;
        }
        if (! self::protected_publish_enabled()) {
            return;
        }
        foreach (get_post_types(['show_ui' => true], 'names') as $post_type) {
            add_meta_box(
                'encypher-emergency-publish',
                __('Emergency publication', 'encypher-provenance'),
                [$this, 'render_emergency_metabox'],
                $post_type,
                'side',
                'low'
            );
        }
    }

    public function render_emergency_metabox($post): void
    {
        wp_nonce_field('encypher_emergency_publish_' . (int) $post->ID, 'encypher_emergency_nonce');
        echo '<label><input type="checkbox" name="encypher_emergency_unsigned" value="1"> ';
        echo esc_html__('Publish this change unsigned', 'encypher-provenance') . '</label>';
        echo '<p><label for="encypher-emergency-reason">' . esc_html__('Audit reason', 'encypher-provenance') . '</label>';
        echo '<input class="widefat" id="encypher-emergency-reason" name="encypher_emergency_reason" type="text"></p>';
        echo '<p class="description">' . esc_html__('Administrators only. The bypass is written to the permanent publication evidence ledger.', 'encypher-provenance') . '</p>';
    }

    // -----------------------------------------------------------------------
    // Revisions
    // -----------------------------------------------------------------------

    public function after_revision_restore(int $post_id, int $revision_id): void
    {
        update_post_meta($post_id, '_encypher_action_type', 'c2pa.edited');
        self::append_event($post_id, 'revision_restored', [
            'event_id' => 'revrestore:' . $post_id . ':' . $revision_id,
            'reason_code' => 'revision_restored',
            'payload' => ['revision_id' => $revision_id],
        ]);
        $this->rest->request_signing($post_id, true);
    }

    private function link_latest_revision(int $post_id): void
    {
        $revisions = wp_get_post_revisions($post_id, ['numberposts' => 1, 'order' => 'DESC']);
        if (empty($revisions)) {
            return;
        }
        $revision = reset($revisions);
        if (! is_object($revision) || empty($revision->ID)) {
            return;
        }
        $receipt = class_exists(PublicationEventLog::class)
            ? PublicationEventLog::current_receipt($post_id)
            : null;
        $record = [
            'instance_id' => (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true),
            'document_id' => (string) get_post_meta($post_id, '_encypher_provenance_document_id', true),
            'manifest_hash' => (string) get_post_meta($post_id, '_encypher_provenance_canonical_hash', true),
            'action' => (string) get_post_meta($post_id, '_encypher_action_type', true) ?: 'c2pa.edited',
            'actor_id' => (int) get_current_user_id(),
            'signed_at' => gmdate('c'),
            // Empty until the receipt's access policy resolves, so a revision
            // never carries a link that would 404.
            'receipt_token' => is_array($receipt) ? (string) $receipt['receipt_token'] : '',
            'receipt_url' => self::receipt_url($post_id),
        ];
        update_post_meta((int) $revision->ID, '_encypher_revision_provenance', $record);
    }

    public function prepare_revision_for_js(array $response, $revision, $post): array
    {
        $record = get_post_meta((int) $revision->ID, '_encypher_revision_provenance', true);
        if (is_array($record) && ! empty($record)) {
            $response['encypherProvenance'] = $record;
        }
        return $response;
    }

    public function register_routes(): void
    {
        add_rewrite_rule(
            '^provenance-receipt/([0-9]+)/([a-f0-9]{16})/?$',
            'index.php?encypher_receipt_post=$matches[1]&encypher_receipt_token=$matches[2]',
            'top'
        );
    }

    /**
     * @param array<int,string> $vars
     * @return array<int,string>
     */
    public function query_vars(array $vars): array
    {
        $vars[] = 'encypher_receipt_post';
        $vars[] = 'encypher_receipt_token';
        $vars[] = 'encypher_receipt_format';
        return $vars;
    }

    // -----------------------------------------------------------------------
    // Receipts
    // -----------------------------------------------------------------------

    /**
     * Who may read a receipt right now.
     *
     * A receipt describes a publication, so the public link exists only while
     * the post is publicly readable. Anything else is operator-only, and a
     * deleted post has no resolvable link at all even though its evidence is
     * retained.
     */
    public static function receipt_availability(int $post_id): string
    {
        $post = $post_id > 0 ? get_post($post_id) : null;
        if (! is_object($post)) {
            return self::RECEIPT_UNAVAILABLE;
        }
        $public = 'publish' === (string) ($post->post_status ?? '')
            && '' === (string) ($post->post_password ?? '');

        return $public ? self::RECEIPT_PUBLIC : self::RECEIPT_OPERATOR_ONLY;
    }

    /**
     * The resolvable public receipt URL, or '' when the policy cannot resolve
     * one. Callers render the receipt action only for a non-empty URL.
     */
    public static function receipt_url(int $post_id): string
    {
        if (self::RECEIPT_PUBLIC !== self::receipt_availability($post_id)) {
            return '';
        }
        if (! class_exists(PublicationEventLog::class)) {
            return '';
        }
        $receipt = PublicationEventLog::current_receipt($post_id);
        $token = is_array($receipt) ? (string) $receipt['receipt_token'] : '';
        if (1 !== preg_match('/\A[a-f0-9]{16}\z/', $token)) {
            return '';
        }
        return home_url('/provenance-receipt/' . $post_id . '/' . $token . '/');
    }

    private static function receipt_token(int $post_id, string $instance_id, string $document_id): string
    {
        return substr(hash('sha256', $post_id . '|' . $instance_id . '|' . $document_id), 0, 16);
    }

    /**
     * @param array<string,mixed> $response_data
     */
    private function record_receipt(
        int $post_id,
        array $response_data = [],
        string $expected_generation = '',
        string $expected_instance = ''
    ): void {
        $state = get_post_meta($post_id, self::STATE_META, true);
        $instance_id = (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true);
        $document_id = (string) get_post_meta($post_id, '_encypher_provenance_document_id', true);
        if ('' !== $expected_generation || '' !== $expected_instance) {
            if (
                ! is_array($state)
                || '' === $expected_generation
                || '' === $expected_instance
                || ! hash_equals($expected_generation, (string) ($state['publication_generation'] ?? ''))
                || ! hash_equals($expected_instance, (string) ($state['committed_instance_id'] ?? ''))
                || ! hash_equals($expected_instance, $instance_id)
            ) {
                return;
            }
        }
        if ('' === $instance_id && '' === $document_id) {
            return;
        }
        if (! class_exists(PublicationEventLog::class)) {
            return;
        }

        if ($state !== get_post_meta($post_id, self::STATE_META, true)) {
            return;
        }
        $stored = PublicationEventLog::record_receipt([
            'receipt_token' => self::receipt_token($post_id, $instance_id, $document_id),
            'post_id' => $post_id,
            'generation' => is_array($state) ? (string) ($state['publication_generation'] ?? '') : '',
            'instance_id' => $instance_id,
            'document_id' => $document_id,
            'canonical_input_digest' => (string) ($response_data['canonical_input_digest'] ?? ''),
            'publisher' => (string) get_bloginfo('name'),
            'canonical_url' => (string) get_permalink($post_id),
            'signed_at' => (string) get_post_meta($post_id, '_encypher_provenance_last_signed', true),
            'integrity' => 'signed_pending_live_verification',
        ]);
        if (is_wp_error($stored) && function_exists('do_action')) {
            do_action('encypher_publication_evidence_unavailable', $post_id, $stored);
        }
    }

    private function update_current_receipt_live_state(
        int $post_id,
        string $generation,
        string $instance_id,
        bool $valid,
        string $error
    ): void {
        if (! class_exists(PublicationEventLog::class)) {
            return;
        }
        $state = get_post_meta($post_id, self::STATE_META, true);
        if (! is_array($state) || ! self::live_worker_owns_state($post_id, $state, $generation, $instance_id)) {
            return;
        }
        $receipt = PublicationEventLog::current_receipt($post_id);
        if (
            ! is_array($receipt)
            || '' === (string) ($receipt['receipt_token'] ?? '')
            || ! hash_equals($generation, (string) ($receipt['generation'] ?? ''))
            || ! hash_equals($instance_id, (string) ($receipt['instance_id'] ?? ''))
        ) {
            return;
        }
        if (! self::live_worker_owns_state($post_id, $state, $generation, $instance_id)) {
            return;
        }
        PublicationEventLog::update_receipt_state((string) $receipt['receipt_token'], [
            'integrity' => $valid ? 'live_verified' : 'live_verification_failed',
            'live_verified_at' => $valid ? gmdate('c') : '',
            'error_text' => $valid ? '' : $error,
        ]);
    }

    public function render_receipt(): void
    {
        $post_id = (int) get_query_var('encypher_receipt_post');
        $token = sanitize_key((string) get_query_var('encypher_receipt_token'));
        if ($post_id <= 0 || 1 !== preg_match('/\A[a-f0-9]{16}\z/', $token)) {
            return;
        }

        $receipt = class_exists(PublicationEventLog::class)
            ? PublicationEventLog::receipt_by_token($token)
            : null;
        $availability = self::receipt_availability($post_id);
        $resolvable = is_array($receipt)
            && $post_id === (int) $receipt['post_id']
            && (
                self::RECEIPT_PUBLIC === $availability
                || (self::RECEIPT_OPERATOR_ONLY === $availability && current_user_can('edit_post', $post_id))
            );
        if (! $resolvable) {
            status_header(404);
            nocache_headers();
            exit;
        }

        $integrity = '' !== (string) $receipt['integrity'] ? (string) $receipt['integrity'] : 'unknown';
        $payload = [
            'publisher' => (string) $receipt['publisher'],
            'canonicalUrl' => (string) $receipt['canonical_url'],
            'signedAt' => (string) $receipt['signed_at'],
            'liveVerifiedAt' => (string) $receipt['live_verified_at'],
            'integrity' => $integrity,
            'generationId' => (string) $receipt['instance_id'],
            'workId' => (string) $receipt['document_id'],
            'supersededAt' => (string) $receipt['superseded_at'],
            'availability' => $availability,
            'receiptVersion' => (int) $receipt['version'],
            'assuranceState' => self::assurance_dimension($post_id)['assurance_state'],
            'receiptUrl' => home_url('/provenance-receipt/' . $post_id . '/' . $token . '/'),
        ];
        $requested_format = isset($_GET['format']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only representation selector.
            ? sanitize_key(wp_unslash((string) $_GET['format'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only representation selector.
            : '';
        if ('json' === (string) get_query_var('encypher_receipt_format') || 'json' === $requested_format) {
            wp_send_json($payload);
        }

        status_header(200);
        nocache_headers();
        header('Content-Type: text/html; charset=' . get_bloginfo('charset'));
        echo '<!doctype html><html><head><meta charset="' . esc_attr(get_bloginfo('charset')) . '">';
        echo '<meta name="viewport" content="width=device-width, initial-scale=1"><title>' . esc_html__('Provenance verification receipt', 'encypher-provenance') . '</title>';
        echo '<style>body{font:16px/1.5 system-ui,sans-serif;max-width:760px;margin:4rem auto;padding:0 1rem;color:#172033}dl{display:grid;grid-template-columns:12rem 1fr;gap:.6rem}dt{font-weight:700}dd{margin:0;overflow-wrap:anywhere}.ok{color:#08783f}.warn{color:#9b4b00}@media print{a{color:inherit}}</style></head><body>';
        echo '<main data-encypher-receipt-root';
        echo ' data-encypher-assurance-state="' . esc_attr($payload['assuranceState']) . '"';
        echo ' data-encypher-receipt-integrity="' . esc_attr($integrity) . '"';
        echo ' data-encypher-generation="' . esc_attr($payload['generationId']) . '"';
        echo ' data-encypher-work-id="' . esc_attr($payload['workId']) . '"';
        echo ' data-encypher-receipt-version="' . esc_attr((string) $payload['receiptVersion']) . '"';
        echo ' data-encypher-availability="' . esc_attr($availability) . '">';
        echo '<h1>' . esc_html__('Provenance verification receipt', 'encypher-provenance') . '</h1>';
        $class = 'live_verified' === $integrity ? 'ok' : 'warn';
        echo '<p class="' . esc_attr($class) . '"><strong>' . esc_html(ucwords(str_replace('_', ' ', $integrity))) . '</strong></p><dl>';
        foreach ([
            __('Publisher', 'encypher-provenance') => $payload['publisher'],
            __('Canonical URL', 'encypher-provenance') => $payload['canonicalUrl'],
            __('Signed at', 'encypher-provenance') => $payload['signedAt'],
            __('Live verified at', 'encypher-provenance') => $payload['liveVerifiedAt'],
            __('Generation ID', 'encypher-provenance') => $payload['generationId'],
            __('Work ID', 'encypher-provenance') => $payload['workId'],
            __('Superseded at', 'encypher-provenance') => $payload['supersededAt'],
        ] as $label => $value) {
            echo '<dt>' . esc_html($label) . '</dt><dd>' . esc_html((string) $value) . '</dd>';
        }
        echo '</dl><p><a href="' . esc_url(add_query_arg('format', 'json', $payload['receiptUrl'])) . '">' . esc_html__('Machine-readable JSON', 'encypher-provenance') . '</a></p></main></body></html>';
        exit;
    }
}
