<?php
/**
 * Append-only publication event and receipt repository.
 *
 * The durable answer to "what happened to this publication, when, and on whose
 * authority". Every fact is one row in a hash-chained ledger:
 *
 * - append is idempotent by caller-supplied `event_id`, so a retried worker,
 *   a replayed hook, or a repeated cron tick records one event;
 * - each row stores `prev_hash` and `entry_hash`; `prev_hash` is UNIQUE, so two
 *   concurrent appends cannot claim one predecessor and the chain cannot fork;
 * - the repository exposes no update or delete path for events, and receipts
 *   change only by appending a new version;
 * - reads page on `chain_seq` (events) and `id` (receipts), both monotonic and
 *   unique, so pagination is stable while the log grows.
 *
 * Legacy `_encypher_publication_audit` and `_encypher_verification_receipts`
 * post meta was mutable, capped, and destroyed with the post. It is imported
 * once per post with deterministic ids and then removed, so history survives
 * but stops being authority.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

use WP_Error;

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

require_once __DIR__ . '/class-encypher-publication-evidence-schema.php';

final class PublicationEventLog
{
    /** Hash-chain domain tag. A change here starts a new chain version. */
    public const CHAIN_VERSION = 'encypher-publication-chain-v1';

    /** Predecessor hash of the first row in a site's chain. */
    public const GENESIS_HASH = '0000000000000000000000000000000000000000000000000000000000000000';

    /**
     * Retention is "keep, and never offer an application mutation path".
     * Evidence is not trimmed, not capped, and not deleted with the post.
     */
    public const RETENTION_MODE = 'application_append_only_retained';

    public const MAX_PAGE = 200;

    /** Bounded attempts when a concurrent append takes our predecessor. */
    private const CHAIN_ATTEMPTS = 5;

    // -----------------------------------------------------------------------
    // Fixed SQL fragments.
    //
    // Every statement in this class is assembled from these constants, the
    // prefixed table name, and `%d`/`%s` placeholders, then handed to
    // `$wpdb->prepare()`. Ordering and paging are chosen from constants rather
    // than interpolated, and no caller value is ever concatenated into SQL.
    // -----------------------------------------------------------------------

    private const EVENT_ORDER_ASC = ' ORDER BY chain_seq ASC';
    private const EVENT_ORDER_DESC = ' ORDER BY chain_seq DESC';
    private const RECEIPT_ORDER_ASC = ' ORDER BY id ASC';
    private const RECEIPT_ORDER_DESC = ' ORDER BY id DESC';
    private const RECEIPT_VERSION_ORDER_DESC = ' ORDER BY version DESC';
    private const EVENT_TYPE_IN_OPEN = ' AND event_type IN (';
    private const EVENT_TYPE_IN_CLOSE = ')';
    private const EVENT_TYPE_NONE = ' AND event_type = %s';
    private const RECEIPT_CURRENT_ONLY = ' AND superseded_at = %s';

    private const LIMIT_ROWS = ' LIMIT %d';
    private const COUNT_ALL = 'COUNT(*)';
    private const ALL_COLUMNS = '*';

    /** Filter name => [fixed SQL fragment, value kind]. */
    private const EVENT_FILTERS = [
        'post_id' => [' AND post_id = %d', 'int'],
        'generation' => [' AND generation = %s', 'string'],
        'receipt_token' => [' AND receipt_token = %s', 'token'],
        'before_sequence' => [' AND chain_seq < %d', 'int_min_one'],
    ];

    /** Filter name => [fixed SQL fragment, value kind]. */
    private const RECEIPT_FILTERS = [
        'post_id' => [' AND post_id = %d', 'int'],
        'receipt_token' => [' AND receipt_token = %s', 'token'],
    ];

    /** Per-post marker recording that legacy post meta was imported. */
    public const MIGRATION_META = '_encypher_publication_evidence_migrated';

    private const LEGACY_AUDIT_META = '_encypher_publication_audit';
    private const LEGACY_RECEIPTS_META = '_encypher_verification_receipts';

    /**
     * Every publication fact this plugin can record. An unknown type is
     * rejected rather than stored, so the history projection stays typed.
     */
    public const EVENT_TYPES = [
        'publication_held',
        'publication_released',
        'publication_cancelled',
        'publication_cancel_rejected',
        'publication_commit_superseded',
        'signing_completed',
        'signing_failed',
        'signing_retry_requested',
        'emergency_unsigned_publish',
        'live_check_verified',
        'live_check_failed',
        'live_recheck_requested',
        'receipt_recorded',
        'receipt_state_updated',
        'receipt_superseded',
        'revision_restored',
        'legacy_audit_imported',
    ];

    /** Legacy post-meta audit names mapped onto the typed vocabulary. */
    private const LEGACY_EVENT_MAP = [
        'publication_held' => 'publication_held',
        'signing_completed' => 'signing_completed',
        'signing_failed' => 'signing_failed',
        'public_delivery_verified' => 'live_check_verified',
        'public_delivery_failed' => 'live_check_failed',
        'emergency_unsigned_publish' => 'emergency_unsigned_publish',
        'revision_restored' => 'revision_restored',
    ];

    // -----------------------------------------------------------------------
    // Availability
    // -----------------------------------------------------------------------

    /**
     * True when a durable substrate exists. A missing substrate is reported to
     * callers as an error; it is never silently treated as a successful write.
     */
    public static function available(): bool
    {
        global $wpdb;
        return is_object($wpdb)
            && is_callable([$wpdb, 'insert'])
            && is_callable([$wpdb, 'prepare'])
            && is_callable([$wpdb, 'get_results']);
    }

    /**
     * @return array{mode:string,application_update:bool,application_delete:bool,survives_post_deletion:bool,hash_chained:bool,note:string}
     */
    public static function retention_policy(): array
    {
        return [
            'mode' => self::RETENTION_MODE,
            'application_update' => false,
            'application_delete' => false,
            'survives_post_deletion' => true,
            'hash_chained' => true,
            'note' => 'Application code cannot update or delete evidence. A database superuser can; the hash chain detects it.',
        ];
    }

    // -----------------------------------------------------------------------
    // Events
    // -----------------------------------------------------------------------

    /**
     * Append one publication event.
     *
     * @param array<string,mixed> $event
     * @return array{event_id:string,sequence:int,entry_hash:string,duplicate:bool}|WP_Error
     */
    public static function append(array $event)
    {
        global $wpdb;

        if (! self::available()) {
            return new WP_Error(
                'publication_evidence_unavailable',
                __('The durable publication evidence store is unavailable.', 'encypher-provenance')
            );
        }

        $type = (string) ($event['event_type'] ?? '');
        if (! in_array($type, self::EVENT_TYPES, true)) {
            return new WP_Error(
                'publication_event_type_invalid',
                __('Unknown publication event type.', 'encypher-provenance')
            );
        }

        $event_id = self::normalize_identifier((string) ($event['event_id'] ?? ''));
        if ('' === $event_id) {
            return new WP_Error(
                'publication_event_id_required',
                __('A publication event requires a stable event id.', 'encypher-provenance')
            );
        }

        PublicationEvidenceSchema::ensure_tables_once();

        $existing = self::event_by_event_id($event_id);
        if (null !== $existing) {
            return self::append_result($existing, true);
        }

        $row = self::normalize_event($event, $event_id, $type);
        $table = PublicationEvidenceSchema::events_table();

        for ($attempt = 0; $attempt < self::CHAIN_ATTEMPTS; $attempt++) {
            $head = self::head();
            $row['chain_seq'] = $head['sequence'] + 1;
            $row['prev_hash'] = $head['entry_hash'];
            $row['entry_hash'] = self::entry_hash($row);

            $wpdb->last_error = '';
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- append into the custom evidence table this class owns; the unique prev_hash and event_id indexes are the concurrency control, so it cannot be cached.
            if (false !== $wpdb->insert($table, $row)) {
                return self::append_result($row, false);
            }

            $error = (string) $wpdb->last_error;
            if (false !== stripos($error, "doesn't exist")) {
                PublicationEvidenceSchema::install();
                continue;
            }

            $existing = self::event_by_event_id($event_id);
            if (null !== $existing) {
                return self::append_result($existing, true);
            }
            if (false === stripos($error, 'duplicate')) {
                return new WP_Error('publication_event_append_failed', $error);
            }
            // Another append took our predecessor. Re-read the head and retry.
        }

        return new WP_Error(
            'publication_event_chain_contended',
            __('The publication evidence chain is contended; the event was not appended.', 'encypher-provenance')
        );
    }

    /**
     * Current chain head for this site.
     *
     * @return array{sequence:int,entry_hash:string}
     */
    public static function head(): array
    {
        $row = self::select_row(
            self::statement(
                'chain_seq, entry_hash',
                PublicationEvidenceSchema::events_table(),
                ' WHERE chain_seq > %d',
                self::EVENT_ORDER_DESC,
                self::LIMIT_ROWS
            ),
            [0, 1]
        );
        if (null === $row || ! isset($row['entry_hash'])) {
            return ['sequence' => 0, 'entry_hash' => self::GENESIS_HASH];
        }
        return [
            'sequence' => (int) $row['chain_seq'],
            'entry_hash' => (string) $row['entry_hash'],
        ];
    }

    /**
     * Read events, oldest first by default so a chain walk is natural.
     *
     * Args: post_id, event_type (string|array), generation, after_sequence,
     * before_sequence, limit, order ('asc'|'desc').
     *
     * @param array<string,mixed> $args
     * @return array<int,array<string,mixed>>
     */
    public static function query(array $args = []): array
    {
        if (! self::available()) {
            return [];
        }
        PublicationEvidenceSchema::ensure_tables_once();

        $order = 'desc' === strtolower((string) ($args['order'] ?? 'asc'))
            ? self::EVENT_ORDER_DESC
            : self::EVENT_ORDER_ASC;
        $where = self::event_where($args);
        $rows = self::select_rows(
            self::statement(
                self::ALL_COLUMNS,
                PublicationEvidenceSchema::events_table(),
                ' WHERE chain_seq > %d',
                $where['sql'],
                $order,
                self::LIMIT_ROWS
            ),
            array_merge(
                [max(0, (int) ($args['after_sequence'] ?? 0))],
                $where['values'],
                [min(self::MAX_PAGE, max(1, (int) ($args['limit'] ?? 50)))]
            )
        );

        return array_map([self::class, 'hydrate_event'], $rows);
    }

    /**
     * @param array<string,mixed> $args
     */
    public static function count(array $args = []): int
    {
        if (! self::available()) {
            return 0;
        }
        PublicationEvidenceSchema::ensure_tables_once();
        $where = self::event_where($args);

        return self::select_int(
            self::statement(
                self::COUNT_ALL,
                PublicationEvidenceSchema::events_table(),
                ' WHERE chain_seq > %d',
                $where['sql']
            ),
            array_merge([0], $where['values'])
        );
    }

    /**
     * Recompute every entry hash and predecessor link in order.
     *
     * Any altered column, deleted row, or reordered sequence surfaces here.
     *
     * @param array<string,mixed> $args
     * @return array{verified:bool,checked:int,head_sequence:int,broken_at:int,reason:string}
     */
    public static function verify_chain(array $args = []): array
    {
        $expected_prev = self::GENESIS_HASH;
        $expected_sequence = 1;
        $checked = 0;
        $after = 0;
        $page = min(self::MAX_PAGE, max(1, (int) ($args['page_size'] ?? self::MAX_PAGE)));

        while (true) {
            $rows = self::query(['after_sequence' => $after, 'limit' => $page, 'order' => 'asc']);
            if (! $rows) {
                break;
            }
            foreach ($rows as $row) {
                $after = (int) $row['sequence'];
                if ((int) $row['sequence'] !== $expected_sequence) {
                    return self::chain_break($checked, (int) $row['sequence'], 'sequence_gap');
                }
                if (! hash_equals($expected_prev, (string) $row['prev_hash'])) {
                    return self::chain_break($checked, (int) $row['sequence'], 'prev_hash_mismatch');
                }
                if (! hash_equals((string) $row['entry_hash'], self::entry_hash(self::chain_fields($row)))) {
                    return self::chain_break($checked, (int) $row['sequence'], 'entry_hash_mismatch');
                }
                $expected_prev = (string) $row['entry_hash'];
                $expected_sequence++;
                $checked++;
            }
        }

        return [
            'verified' => true,
            'checked' => $checked,
            'head_sequence' => $expected_sequence - 1,
            'broken_at' => 0,
            'reason' => '',
        ];
    }

    /**
     * One export page: normalized rows plus the chain facts a verifier needs.
     *
     * @param array<string,mixed> $args
     * @return array{retention:array<string,mixed>,chain_version:string,head:array{sequence:int,entry_hash:string},next_after_sequence:int,events:array<int,array<string,mixed>>}
     */
    public static function export(array $args = []): array
    {
        $events = self::query(array_merge($args, ['order' => 'asc']));
        $last = $events ? (int) $events[count($events) - 1]['sequence'] : (int) ($args['after_sequence'] ?? 0);

        return [
            'retention' => self::retention_policy(),
            'chain_version' => self::CHAIN_VERSION,
            'head' => self::head(),
            'next_after_sequence' => $last,
            'events' => $events,
        ];
    }

    /**
     * Typed operator history for one post, oldest first and stably ordered.
     *
     * @param array<string,mixed> $args
     * @return array<int,array<string,mixed>>
     */
    public static function status_history(int $post_id, array $args = []): array
    {
        if ($post_id > 0) {
            self::ensure_migrated($post_id);
        }

        $rows = self::query(array_merge($args, ['post_id' => $post_id]));

        return array_map(
            static function (array $row): array {
                return [
                    'sequence' => $row['sequence'],
                    'event_id' => $row['event_id'],
                    'type' => $row['event_type'],
                    'at' => $row['occurred_at'],
                    'actor_id' => $row['actor_id'],
                    'actor_kind' => $row['actor_kind'],
                    'reason_code' => $row['reason_code'],
                    'reason' => $row['reason_text'],
                    'generation' => $row['generation'],
                    'instance_id' => $row['instance_id'],
                    'requested_status' => $row['requested_status'],
                    'observed_status' => $row['observed_status'],
                    'receipt_token' => $row['receipt_token'],
                    'detail' => $row['payload'],
                ];
            },
            $rows
        );
    }

    /**
     * The visible provenance chain for one post.
     *
     * One entry per recorded publication fact, plus the per-generation roll-up
     * a surface needs to show "what happened to which signed generation": the
     * signed identifiers, the terminal outcome, and the receipt token.
     *
     * Evidence lives in this ledger rather than in post meta, so a chain still
     * resolves after the post row is deleted. `post_exists` says which case the
     * caller is looking at.
     *
     * @param array<string,mixed> $args limit, after_sequence, order
     * @return array{available:bool,post_exists:bool,head_sequence:int,generations:array<int,array<string,mixed>>,entries:array<int,array<string,mixed>>}
     */
    public static function chain(int $post_id, array $args = []): array
    {
        if (! self::available()) {
            return [
                'available' => false,
                'post_exists' => false,
                'head_sequence' => 0,
                'generations' => [],
                'entries' => [],
            ];
        }
        if ($post_id > 0) {
            self::ensure_migrated($post_id);
        }

        $rows = self::query(array_merge(
            ['limit' => 50, 'order' => 'asc'],
            $args,
            ['post_id' => $post_id]
        ));

        $entries = [];
        $generations = [];
        foreach ($rows as $row) {
            $entries[] = [
                'sequence' => (int) $row['sequence'],
                'event_id' => (string) $row['event_id'],
                'type' => (string) $row['event_type'],
                'at' => (string) $row['occurred_at'],
                'actor_id' => (int) $row['actor_id'],
                'actor_kind' => (string) $row['actor_kind'],
                'reason_code' => (string) $row['reason_code'],
                'reason' => (string) $row['reason_text'],
                'generation' => (string) $row['generation'],
                'instance_id' => (string) $row['instance_id'],
                'document_id' => (string) $row['document_id'],
                'requested_status' => (string) $row['requested_status'],
                'observed_status' => (string) $row['observed_status'],
                'receipt_token' => (string) $row['receipt_token'],
                'detail' => $row['payload'],
            ];

            $generation = (string) $row['generation'];
            if ('' === $generation) {
                continue;
            }
            if (! isset($generations[$generation])) {
                $generations[$generation] = [
                    'generation' => $generation,
                    'first_sequence' => (int) $row['sequence'],
                    'last_sequence' => (int) $row['sequence'],
                    'instance_id' => '',
                    'document_id' => '',
                    'receipt_token' => '',
                    'outcome' => '',
                    'held_at' => '',
                    'released_at' => '',
                    'cancelled_at' => '',
                    'failure_code' => '',
                    'live_state' => '',
                    'events' => [],
                ];
            }
            $entry = &$generations[$generation];
            $entry['last_sequence'] = (int) $row['sequence'];
            $entry['events'][] = (string) $row['event_type'];
            foreach (['instance_id', 'document_id', 'receipt_token'] as $field) {
                if ('' !== (string) $row[$field]) {
                    $entry[$field] = (string) $row[$field];
                }
            }
            switch ((string) $row['event_type']) {
                case 'publication_held':
                    $entry['held_at'] = (string) $row['occurred_at'];
                    break;
                case 'publication_released':
                    $entry['outcome'] = 'committed';
                    $entry['released_at'] = (string) $row['occurred_at'];
                    $entry['failure_code'] = '';
                    break;
                case 'publication_cancelled':
                    $entry['outcome'] = 'cancelled';
                    $entry['cancelled_at'] = (string) $row['occurred_at'];
                    break;
                case 'signing_failed':
                    $entry['failure_code'] = (string) $row['reason_code'];
                    break;
                case 'live_check_verified':
                    $entry['live_state'] = 'live_verified';
                    break;
                case 'live_check_failed':
                    $entry['live_state'] = 'live_verification_failed';
                    break;
            }
            unset($entry);
        }

        return [
            'available' => true,
            'post_exists' => $post_id > 0 && function_exists('get_post') && (bool) get_post($post_id),
            'head_sequence' => $entries ? (int) $entries[count($entries) - 1]['sequence'] : 0,
            'generations' => array_values($generations),
            'entries' => $entries,
        ];
    }

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

    /**
     * Record a new receipt for a signing generation.
     *
     * Any earlier unsuperseded receipt for the post is superseded first, by
     * appending a version, so the newest row is always the current receipt.
     *
     * @param array<string,mixed> $receipt
     * @return array<string,mixed>|WP_Error
     */
    public static function record_receipt(array $receipt)
    {
        $token = self::normalize_token((string) ($receipt['receipt_token'] ?? ''));
        if ('' === $token) {
            return new WP_Error(
                'publication_receipt_token_invalid',
                __('A receipt requires a 16-character hexadecimal token.', 'encypher-provenance')
            );
        }
        $post_id = (int) ($receipt['post_id'] ?? 0);

        if (self::available()) {
            PublicationEvidenceSchema::ensure_tables_once();
            foreach (self::unsuperseded_receipts($post_id, $token) as $stale) {
                self::put_receipt_version(
                    (string) $stale['receipt_token'],
                    array_merge($stale, ['superseded_at' => self::now_iso()]),
                    'receipt_superseded'
                );
            }
        }

        return self::put_receipt_version($token, [
            'post_id' => $post_id,
            'generation' => (string) ($receipt['generation'] ?? ''),
            'instance_id' => (string) ($receipt['instance_id'] ?? ''),
            'document_id' => (string) ($receipt['document_id'] ?? ''),
            'canonical_input_digest' => (string) ($receipt['canonical_input_digest'] ?? ''),
            'publisher' => (string) ($receipt['publisher'] ?? ''),
            'canonical_url' => (string) ($receipt['canonical_url'] ?? ''),
            'integrity' => (string) ($receipt['integrity'] ?? 'signed_pending_live_verification'),
            'availability_policy' => (string) ($receipt['availability_policy'] ?? 'post_public_only'),
            'signed_at' => (string) ($receipt['signed_at'] ?? ''),
            'live_verified_at' => (string) ($receipt['live_verified_at'] ?? ''),
            'superseded_at' => '',
            'error_text' => (string) ($receipt['error_text'] ?? ''),
        ], 'receipt_recorded');
    }

    /**
     * Append a new version of an existing receipt with a changed live state.
     *
     * @param array<string,mixed> $changes
     * @return array<string,mixed>|WP_Error
     */
    public static function update_receipt_state(string $token, array $changes)
    {
        $current = self::receipt_by_token($token);
        if (null === $current) {
            return new WP_Error(
                'publication_receipt_missing',
                __('No receipt exists for that token.', 'encypher-provenance')
            );
        }
        return self::put_receipt_version($token, array_merge($current, $changes), 'receipt_state_updated');
    }

    /**
     * The current receipt for a post: the newest appended version.
     *
     * @return array<string,mixed>|null
     */
    public static function current_receipt(int $post_id)
    {
        if ($post_id <= 0 || ! self::available()) {
            return null;
        }
        self::ensure_migrated($post_id);
        PublicationEvidenceSchema::ensure_tables_once();

        $row = self::select_row(
            self::statement(
                self::ALL_COLUMNS,
                PublicationEvidenceSchema::receipts_table(),
                ' WHERE post_id = %d',
                self::RECEIPT_ORDER_DESC,
                self::LIMIT_ROWS
            ),
            [$post_id, 1]
        );

        return null === $row ? null : self::hydrate_receipt($row);
    }

    /**
     * The newest version of one receipt token.
     *
     * @return array<string,mixed>|null
     */
    public static function receipt_by_token(string $token)
    {
        $token = self::normalize_token($token);
        if ('' === $token || ! self::available()) {
            return null;
        }
        PublicationEvidenceSchema::ensure_tables_once();

        $row = self::select_row(
            self::statement(
                self::ALL_COLUMNS,
                PublicationEvidenceSchema::receipts_table(),
                ' WHERE receipt_token = %s',
                self::RECEIPT_VERSION_ORDER_DESC,
                self::LIMIT_ROWS
            ),
            [$token, 1]
        );

        return null === $row ? null : self::hydrate_receipt($row);
    }

    /**
     * Paged receipt versions. Args: post_id, receipt_token, after_id, limit,
     * order ('asc'|'desc').
     *
     * @param array<string,mixed> $args
     * @return array<int,array<string,mixed>>
     */
    public static function list_receipts(array $args = []): array
    {
        if (! self::available()) {
            return [];
        }
        PublicationEvidenceSchema::ensure_tables_once();

        $order = 'desc' === strtolower((string) ($args['order'] ?? 'asc'))
            ? self::RECEIPT_ORDER_DESC
            : self::RECEIPT_ORDER_ASC;
        $where = self::receipt_where($args);
        $rows = self::select_rows(
            self::statement(
                self::ALL_COLUMNS,
                PublicationEvidenceSchema::receipts_table(),
                ' WHERE id > %d',
                $where['sql'],
                $order,
                self::LIMIT_ROWS
            ),
            array_merge(
                [max(0, (int) ($args['after_id'] ?? 0))],
                $where['values'],
                [min(self::MAX_PAGE, max(1, (int) ($args['limit'] ?? 50)))]
            )
        );

        return array_map([self::class, 'hydrate_receipt'], $rows);
    }

    /**
     * @param array<string,mixed> $args
     */
    public static function count_receipts(array $args = []): int
    {
        if (! self::available()) {
            return 0;
        }
        PublicationEvidenceSchema::ensure_tables_once();
        $where = self::receipt_where($args);

        return self::select_int(
            self::statement(
                self::COUNT_ALL,
                PublicationEvidenceSchema::receipts_table(),
                ' WHERE id > %d',
                $where['sql']
            ),
            array_merge([0], $where['values'])
        );
    }

    // -----------------------------------------------------------------------
    // Legacy post-meta migration
    // -----------------------------------------------------------------------

    /**
     * Import legacy audit and receipt post meta once, then remove it.
     *
     * Deterministic ids make the import idempotent even if it is interrupted
     * before the legacy meta is deleted.
     *
     * @return int Rows imported by this call.
     */
    public static function import_legacy_post_meta(int $post_id): int
    {
        if ($post_id <= 0 || ! self::available()) {
            return 0;
        }

        $audit = get_post_meta($post_id, self::LEGACY_AUDIT_META, true);
        $receipts = get_post_meta($post_id, self::LEGACY_RECEIPTS_META, true);
        $imported = 0;

        foreach (is_array($audit) ? array_values($audit) : [] as $index => $entry) {
            if (! is_array($entry)) {
                continue;
            }
            $legacy_event = (string) ($entry['event'] ?? '');
            $type = self::LEGACY_EVENT_MAP[$legacy_event] ?? 'legacy_audit_imported';
            $appended = self::append([
                'event_id' => 'legacy-audit:' . $post_id . ':' . $index . ':'
                    . substr(hash('sha256', (string) wp_json_encode($entry)), 0, 16),
                'event_type' => $type,
                'post_id' => $post_id,
                'actor_id' => (int) ($entry['actor_id'] ?? 0),
                'actor_kind' => (int) ($entry['actor_id'] ?? 0) > 0 ? 'user' : 'system',
                'occurred_at' => (string) ($entry['at'] ?? ''),
                'payload' => [
                    'imported_from' => 'post_meta',
                    'legacy_event' => $legacy_event,
                    'legacy_detail' => (string) ($entry['detail'] ?? ''),
                ],
            ]);
            if (! is_wp_error($appended) && empty($appended['duplicate'])) {
                $imported++;
            }
        }

        foreach (is_array($receipts) ? array_values($receipts) : [] as $entry) {
            if (! is_array($entry)) {
                continue;
            }
            $token = self::normalize_token((string) ($entry['token'] ?? ''));
            if ('' === $token || null !== self::receipt_by_token($token)) {
                continue;
            }
            $stored = self::put_receipt_version($token, [
                'post_id' => $post_id,
                'generation' => '',
                'instance_id' => (string) ($entry['instance_id'] ?? ''),
                'document_id' => (string) ($entry['document_id'] ?? ''),
                'publisher' => (string) ($entry['publisher'] ?? ''),
                'canonical_url' => (string) ($entry['canonical_url'] ?? ''),
                'integrity' => (string) ($entry['integrity'] ?? 'unknown'),
                'signed_at' => (string) ($entry['signed_at'] ?? ''),
                'live_verified_at' => (string) ($entry['live_verified_at'] ?? ''),
                'superseded_at' => (string) ($entry['superseded_at'] ?? ''),
                'error_text' => (string) ($entry['error'] ?? ''),
            ], 'receipt_recorded');
            if (! is_wp_error($stored)) {
                $imported++;
            }
        }

        delete_post_meta($post_id, self::LEGACY_AUDIT_META);
        delete_post_meta($post_id, self::LEGACY_RECEIPTS_META);
        update_post_meta($post_id, self::MIGRATION_META, PublicationEvidenceSchema::SCHEMA_VERSION);

        return $imported;
    }

    /**
     * Import legacy meta the first time a post's evidence is touched.
     *
     * A deleted post has no meta left to import and must not gain an orphan
     * migration marker, so it is skipped: its evidence is already durable here.
     */
    public static function ensure_migrated(int $post_id): void
    {
        if (
            $post_id <= 0
            || (int) get_post_meta($post_id, self::MIGRATION_META, true) >= PublicationEvidenceSchema::SCHEMA_VERSION
            || (function_exists('get_post') && ! get_post($post_id))
        ) {
            return;
        }
        self::import_legacy_post_meta($post_id);
    }

    // -----------------------------------------------------------------------
    // Internals
    // -----------------------------------------------------------------------

    /**
     * @param array<string,mixed> $event
     * @return array<string,mixed>
     */
    private static function normalize_event(array $event, string $event_id, string $type): array
    {
        $actor_id = max(0, (int) ($event['actor_id'] ?? 0));
        $actor_kind = 'user' === (string) ($event['actor_kind'] ?? '') || $actor_id > 0 ? 'user' : 'system';
        $payload = is_array($event['payload'] ?? null) ? $event['payload'] : [];

        return [
            'event_id' => $event_id,
            'chain_seq' => 0,
            'prev_hash' => self::GENESIS_HASH,
            'entry_hash' => '',
            'site_id' => function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0,
            'post_id' => max(0, (int) ($event['post_id'] ?? 0)),
            'event_type' => $type,
            'generation' => self::normalize_identifier((string) ($event['generation'] ?? ''), 64),
            'instance_id' => self::clip((string) ($event['instance_id'] ?? ''), 191),
            'document_id' => self::clip((string) ($event['document_id'] ?? ''), 191),
            'receipt_token' => self::normalize_token((string) ($event['receipt_token'] ?? '')),
            'requested_status' => self::clip(sanitize_key((string) ($event['requested_status'] ?? '')), 20),
            'observed_status' => self::clip(sanitize_key((string) ($event['observed_status'] ?? '')), 20),
            'actor_kind' => $actor_kind,
            'actor_id' => $actor_id,
            'reason_code' => self::clip(sanitize_key((string) ($event['reason_code'] ?? '')), 64),
            'reason_text' => sanitize_text_field((string) ($event['reason_text'] ?? '')),
            'canonical_url' => self::clip((string) ($event['canonical_url'] ?? ''), 255),
            'payload_json' => (string) wp_json_encode(self::canonicalize($payload)),
            'occurred_at' => self::to_mysql_datetime((string) ($event['occurred_at'] ?? '')),
        ];
    }

    /**
     * The exact field set the chain hash covers, in a fixed order.
     *
     * @param array<string,mixed> $row
     */
    private static function entry_hash(array $row): string
    {
        return hash('sha256', implode("\x1f", [
            self::CHAIN_VERSION,
            (string) $row['prev_hash'],
            (string) (int) $row['chain_seq'],
            (string) $row['event_id'],
            (string) $row['event_type'],
            (string) (int) $row['site_id'],
            (string) (int) $row['post_id'],
            (string) $row['generation'],
            (string) $row['instance_id'],
            (string) $row['document_id'],
            (string) $row['receipt_token'],
            (string) $row['requested_status'],
            (string) $row['observed_status'],
            (string) $row['actor_kind'],
            (string) (int) $row['actor_id'],
            (string) $row['reason_code'],
            (string) $row['reason_text'],
            (string) $row['canonical_url'],
            (string) $row['payload_json'],
            (string) $row['occurred_at'],
        ]));
    }

    /**
     * Rebuild the hashed field set from a hydrated row.
     *
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private static function chain_fields(array $row): array
    {
        return [
            'prev_hash' => $row['prev_hash'],
            'chain_seq' => $row['sequence'],
            'event_id' => $row['event_id'],
            'event_type' => $row['event_type'],
            'site_id' => $row['site_id'],
            'post_id' => $row['post_id'],
            'generation' => $row['generation'],
            'instance_id' => $row['instance_id'],
            'document_id' => $row['document_id'],
            'receipt_token' => $row['receipt_token'],
            'requested_status' => $row['requested_status'],
            'observed_status' => $row['observed_status'],
            'actor_kind' => $row['actor_kind'],
            'actor_id' => $row['actor_id'],
            'reason_code' => $row['reason_code'],
            'reason_text' => $row['reason_text'],
            'canonical_url' => $row['canonical_url'],
            'payload_json' => $row['payload_json'],
            'occurred_at' => $row['occurred_at'],
        ];
    }

    /**
     * @return array{verified:bool,checked:int,head_sequence:int,broken_at:int,reason:string}
     */
    private static function chain_break(int $checked, int $sequence, string $reason): array
    {
        return [
            'verified' => false,
            'checked' => $checked,
            'head_sequence' => $checked,
            'broken_at' => $sequence,
            'reason' => $reason,
        ];
    }

    /**
     * @param array<string,mixed> $row
     * @return array{event_id:string,sequence:int,entry_hash:string,duplicate:bool}
     */
    private static function append_result(array $row, bool $duplicate): array
    {
        return [
            'event_id' => (string) $row['event_id'],
            'sequence' => (int) ($row['sequence'] ?? $row['chain_seq'] ?? 0),
            'entry_hash' => (string) $row['entry_hash'],
            'duplicate' => $duplicate,
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    private static function event_by_event_id(string $event_id)
    {
        $row = self::select_row(
            self::statement(
                self::ALL_COLUMNS,
                PublicationEvidenceSchema::events_table(),
                ' WHERE event_id = %s',
                self::LIMIT_ROWS
            ),
            [$event_id, 1]
        );

        return null === $row ? null : self::hydrate_event($row);
    }

    /**
     * @return array<string,mixed>|null
     */
    private static function receipt_by_event_id(string $event_id)
    {
        return self::select_row(
            self::statement(
                self::ALL_COLUMNS,
                PublicationEvidenceSchema::receipts_table(),
                ' WHERE event_id = %s',
                self::LIMIT_ROWS
            ),
            [$event_id, 1]
        );
    }

    /**
     * Compose one statement from a prefixed table name and fixed fragments.
     *
     * Every argument is either `$wpdb->prefix` plus a class constant or a class
     * constant, so no request data can reach the statement text. Caller values
     * travel separately as `$wpdb->prepare()` placeholder arguments.
     */
    private static function statement(string $columns, string $table, string ...$fragments): string
    {
        return 'SELECT ' . $columns . ' FROM ' . $table . implode('', $fragments);
    }

    /**
     * The three read seams for the owned evidence tables.
     *
     * Every statement reaching them is composed by {@see self::statement()}
     * from the prefixed table name and class constants, and every caller value
     * is bound as a placeholder argument, so request data can never become SQL.
     * PHPCS cannot follow a composed statement into `$wpdb->prepare()`, and a
     * custom table name can never be a literal, so the suppression lives on
     * these three lines instead of at a dozen call sites.
     *
     * @param array<int,mixed> $values
     * @return array<int,array<string,mixed>>
     */
    private static function select_rows(string $sql, array $values): array
    {
        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- statement is composed from the prefixed table name and class constants only and every value is a bound placeholder; this class owns the table and its ledger reads must bypass object caches.
        $rows = $wpdb->get_results($wpdb->prepare($sql, $values), ARRAY_A);

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

    /**
     * @param array<int,mixed> $values
     * @return array<string,mixed>|null
     */
    private static function select_row(string $sql, array $values)
    {
        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- statement is composed from the prefixed table name and class constants only and every value is a bound placeholder; this class owns the table and its ledger reads must bypass object caches.
        $row = $wpdb->get_row($wpdb->prepare($sql, $values), ARRAY_A);

        return is_array($row) ? $row : null;
    }

    /**
     * @param array<int,mixed> $values
     */
    private static function select_int(string $sql, array $values): int
    {
        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- statement is composed from the prefixed table name and class constants only and every value is a bound placeholder; this class owns the table and its ledger reads must bypass object caches.
        return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
    }

    /**
     * Assemble a WHERE tail from fixed fragments plus bindable values.
     *
     * Only the constant fragments in FILTERS ever become SQL; every caller
     * value leaves here as a placeholder argument, so no request data is ever
     * concatenated into a statement.
     *
     * @param array<string,array{0:string,1:string}> $filters
     * @param array<string,mixed> $args
     * @return array{sql:string,values:array<int,mixed>}
     */
    private static function where_from_filters(array $filters, array $args): array
    {
        $sql = '';
        $values = [];
        foreach ($filters as $name => [$fragment, $kind]) {
            if (! isset($args[$name]) || ('int_min_one' === $kind && empty($args[$name]))) {
                continue;
            }
            $sql .= $fragment;
            $values[] = match ($kind) {
                'int' => (int) $args[$name],
                'int_min_one' => max(1, (int) $args[$name]),
                'token' => self::normalize_token((string) $args[$name]),
                default => (string) $args[$name],
            };
        }

        return ['sql' => $sql, 'values' => $values];
    }

    /**
     * @param array<string,mixed> $args
     * @return array{sql:string,values:array<int,mixed>}
     */
    private static function event_where(array $args): array
    {
        $where = self::where_from_filters(self::EVENT_FILTERS, $args);

        $types = $args['event_type'] ?? null;
        if (is_string($types) && '' !== $types) {
            $types = [$types];
        }
        if (is_array($types) && $types) {
            $allowed = array_values(array_intersect(self::EVENT_TYPES, array_map('strval', $types)));
            if (! $allowed) {
                // An unknown filter must match nothing, never everything.
                $where['sql'] .= self::EVENT_TYPE_NONE;
                $where['values'][] = '';
                return $where;
            }
            $where['sql'] .= self::EVENT_TYPE_IN_OPEN
                . implode(',', array_fill(0, count($allowed), '%s'))
                . self::EVENT_TYPE_IN_CLOSE;
            $where['values'] = array_merge($where['values'], $allowed);
        }

        return $where;
    }

    /**
     * @param array<string,mixed> $args
     * @return array{sql:string,values:array<int,mixed>}
     */
    private static function receipt_where(array $args): array
    {
        $where = self::where_from_filters(self::RECEIPT_FILTERS, $args);
        if (! empty($args['current'])) {
            $where['sql'] .= self::RECEIPT_CURRENT_ONLY;
            $where['values'][] = '';
        }

        return $where;
    }

    /**
     * Receipts for a post that are neither superseded nor the incoming token.
     *
     * @return array<int,array<string,mixed>>
     */
    private static function unsuperseded_receipts(int $post_id, string $incoming_token): array
    {
        if ($post_id <= 0) {
            return [];
        }
        $current = self::list_receipts([
            'post_id' => $post_id,
            'current' => true,
            'order' => 'desc',
            'limit' => self::MAX_PAGE,
        ]);

        $latest = [];
        foreach ($current as $row) {
            $token = (string) $row['receipt_token'];
            if ($token === $incoming_token || isset($latest[$token])) {
                continue;
            }
            $newest = self::receipt_by_token($token);
            if (is_array($newest) && '' === (string) $newest['superseded_at']) {
                $latest[$token] = $newest;
            }
        }

        return array_values($latest);
    }

    /**
     * Append one receipt version. Idempotent: the version's event id is derived
     * from its exact content, so replaying an identical state is a no-op.
     *
     * @param array<string,mixed> $fields
     * @return array<string,mixed>|WP_Error
     */
    private static function put_receipt_version(string $token, array $fields, string $event_type)
    {
        global $wpdb;

        $token = self::normalize_token($token);
        if ('' === $token) {
            return new WP_Error(
                'publication_receipt_token_invalid',
                __('A receipt requires a 16-character hexadecimal token.', 'encypher-provenance')
            );
        }
        if (! self::available()) {
            return new WP_Error(
                'publication_evidence_unavailable',
                __('The durable publication evidence store is unavailable.', 'encypher-provenance')
            );
        }
        PublicationEvidenceSchema::ensure_tables_once();

        $row = [
            'receipt_token' => $token,
            'version' => 1,
            'event_id' => '',
            'site_id' => function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0,
            'post_id' => max(0, (int) ($fields['post_id'] ?? 0)),
            'generation' => self::normalize_identifier((string) ($fields['generation'] ?? ''), 64),
            'instance_id' => self::clip((string) ($fields['instance_id'] ?? ''), 191),
            'document_id' => self::clip((string) ($fields['document_id'] ?? ''), 191),
            'canonical_input_digest' => self::clip((string) ($fields['canonical_input_digest'] ?? ''), 191),
            'publisher' => self::clip((string) ($fields['publisher'] ?? ''), 255),
            'canonical_url' => self::clip((string) ($fields['canonical_url'] ?? ''), 255),
            'integrity' => self::clip(sanitize_key((string) ($fields['integrity'] ?? '')), 40),
            'availability_policy' => 'post_public_only',
            'signed_at' => self::clip((string) ($fields['signed_at'] ?? ''), 40),
            'live_verified_at' => self::clip((string) ($fields['live_verified_at'] ?? ''), 40),
            'superseded_at' => self::clip((string) ($fields['superseded_at'] ?? ''), 40),
            'error_text' => sanitize_text_field((string) ($fields['error_text'] ?? '')),
            'recorded_at' => self::to_mysql_datetime(''),
        ];
        $row['event_id'] = 'receipt:' . $token . ':' . substr(
            hash('sha256', (string) wp_json_encode(self::receipt_state_fields($row))),
            0,
            32
        );

        $existing = self::receipt_by_event_id((string) $row['event_id']);
        if (null !== $existing) {
            return self::hydrate_receipt($existing);
        }

        $latest = self::receipt_by_token($token);
        $row['version'] = null === $latest ? 1 : (int) $latest['version'] + 1;

        $wpdb->last_error = '';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- append into the custom receipts table this class owns; the unique event_id and (receipt_token, version) indexes are the concurrency control, so it cannot be cached.
        if (false === $wpdb->insert(PublicationEvidenceSchema::receipts_table(), $row)) {
            $error = (string) $wpdb->last_error;
            $raced = self::receipt_by_event_id((string) $row['event_id']);
            if (null !== $raced) {
                return self::hydrate_receipt($raced);
            }
            return new WP_Error('publication_receipt_append_failed', $error);
        }

        $event = self::append([
            'event_id' => 'receipt-event:' . $row['event_id'],
            'event_type' => $event_type,
            'post_id' => $row['post_id'],
            'generation' => $row['generation'],
            'instance_id' => $row['instance_id'],
            'document_id' => $row['document_id'],
            'receipt_token' => $token,
            'reason_code' => $row['integrity'],
            'canonical_url' => $row['canonical_url'],
            'payload' => [
                'receipt_version' => $row['version'],
                'integrity' => $row['integrity'],
                'live_verified_at' => $row['live_verified_at'],
                'superseded_at' => $row['superseded_at'],
            ],
        ]);
        if (is_wp_error($event)) {
            do_action('encypher_publication_evidence_unavailable', $row['post_id'], $event);
        }

        return self::hydrate_receipt($row);
    }

    /**
     * The receipt fields whose change makes a new version, in a fixed order.
     *
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private static function receipt_state_fields(array $row): array
    {
        return [
            'post_id' => (int) $row['post_id'],
            'generation' => (string) $row['generation'],
            'instance_id' => (string) $row['instance_id'],
            'document_id' => (string) $row['document_id'],
            'canonical_input_digest' => (string) $row['canonical_input_digest'],
            'publisher' => (string) $row['publisher'],
            'canonical_url' => (string) $row['canonical_url'],
            'integrity' => (string) $row['integrity'],
            'signed_at' => (string) $row['signed_at'],
            'live_verified_at' => (string) $row['live_verified_at'],
            'superseded_at' => (string) $row['superseded_at'],
            'error_text' => (string) $row['error_text'],
        ];
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private static function hydrate_event(array $row): array
    {
        $payload = json_decode((string) ($row['payload_json'] ?? ''), true);

        return [
            'id' => (int) ($row['id'] ?? 0),
            'event_id' => (string) ($row['event_id'] ?? ''),
            'sequence' => (int) ($row['chain_seq'] ?? 0),
            'prev_hash' => (string) ($row['prev_hash'] ?? ''),
            'entry_hash' => (string) ($row['entry_hash'] ?? ''),
            'site_id' => (int) ($row['site_id'] ?? 0),
            'post_id' => (int) ($row['post_id'] ?? 0),
            'event_type' => (string) ($row['event_type'] ?? ''),
            'generation' => (string) ($row['generation'] ?? ''),
            'instance_id' => (string) ($row['instance_id'] ?? ''),
            'document_id' => (string) ($row['document_id'] ?? ''),
            'receipt_token' => (string) ($row['receipt_token'] ?? ''),
            'requested_status' => (string) ($row['requested_status'] ?? ''),
            'observed_status' => (string) ($row['observed_status'] ?? ''),
            'actor_kind' => (string) ($row['actor_kind'] ?? 'system'),
            'actor_id' => (int) ($row['actor_id'] ?? 0),
            'reason_code' => (string) ($row['reason_code'] ?? ''),
            'reason_text' => (string) ($row['reason_text'] ?? ''),
            'canonical_url' => (string) ($row['canonical_url'] ?? ''),
            'payload_json' => (string) ($row['payload_json'] ?? ''),
            'payload' => is_array($payload) ? $payload : [],
            'occurred_at' => (string) ($row['occurred_at'] ?? ''),
        ];
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private static function hydrate_receipt(array $row): array
    {
        return [
            'id' => (int) ($row['id'] ?? 0),
            'receipt_token' => (string) ($row['receipt_token'] ?? ''),
            'version' => (int) ($row['version'] ?? 0),
            'event_id' => (string) ($row['event_id'] ?? ''),
            'site_id' => (int) ($row['site_id'] ?? 0),
            'post_id' => (int) ($row['post_id'] ?? 0),
            'generation' => (string) ($row['generation'] ?? ''),
            'instance_id' => (string) ($row['instance_id'] ?? ''),
            'document_id' => (string) ($row['document_id'] ?? ''),
            'canonical_input_digest' => (string) ($row['canonical_input_digest'] ?? ''),
            'publisher' => (string) ($row['publisher'] ?? ''),
            'canonical_url' => (string) ($row['canonical_url'] ?? ''),
            'integrity' => (string) ($row['integrity'] ?? ''),
            'availability_policy' => (string) ($row['availability_policy'] ?? 'post_public_only'),
            'signed_at' => (string) ($row['signed_at'] ?? ''),
            'live_verified_at' => (string) ($row['live_verified_at'] ?? ''),
            'superseded_at' => (string) ($row['superseded_at'] ?? ''),
            'error_text' => (string) ($row['error_text'] ?? ''),
            'recorded_at' => (string) ($row['recorded_at'] ?? ''),
        ];
    }

    /**
     * Recursively key-sort so one payload always serializes to one string.
     *
     * @param mixed $value
     * @return mixed
     */
    private static function canonicalize($value)
    {
        if (! is_array($value)) {
            return $value;
        }
        $normalized = [];
        foreach ($value as $key => $item) {
            $normalized[$key] = self::canonicalize($item);
        }
        if (array_keys($normalized) !== range(0, count($normalized) - 1)) {
            ksort($normalized);
        }
        return $normalized;
    }

    private static function normalize_identifier(string $value, int $max = 191): string
    {
        return self::clip((string) preg_replace('/[^A-Za-z0-9_:.\-]/', '', $value), $max);
    }

    private static function normalize_token(string $token): string
    {
        $token = strtolower(trim($token));
        return 1 === preg_match('/\A[a-f0-9]{16}\z/', $token) ? $token : '';
    }

    private static function clip(string $value, int $max): string
    {
        return strlen($value) > $max ? substr($value, 0, $max) : $value;
    }

    private static function now_iso(): string
    {
        return gmdate('c');
    }

    /**
     * Coerce a caller timestamp to a storable UTC datetime, defaulting to now.
     */
    private static function to_mysql_datetime(string $value): string
    {
        $value = trim($value);
        if ('' === $value) {
            return gmdate('Y-m-d H:i:s');
        }
        $timestamp = strtotime($value);
        return false === $timestamp
            ? gmdate('Y-m-d H:i:s')
            : gmdate('Y-m-d H:i:s', $timestamp);
    }
}
