<?php
/**
 * Durable publication-evidence schema.
 *
 * Two site-local tables and nothing else. `encypher_publication_events` is an
 * append-only, hash-chained ledger of publication facts; each row's
 * `prev_hash` carries a UNIQUE index, so two concurrent appends cannot both
 * claim the same predecessor and the chain cannot fork. Receipts are stored as
 * append-only versions keyed by `(receipt_token, version)`, so a live-state
 * change adds a row instead of rewriting one.
 *
 * "Append-only" here means the application exposes no update or delete path
 * and the chain detects mutation. It is not a claim of protection from a
 * database superuser, who can always alter tables directly.
 *
 * Evidence lives in its own tables, not post meta, so it survives post
 * deletion, is not capped by a serialized array, and can be paged and
 * exported.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

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

final class PublicationEvidenceSchema
{
    public const SCHEMA_VERSION = 1;
    public const VERSION_OPTION = 'encypher_publication_evidence_schema_version';

    /** Bounded network-provisioning cursor, network-wide. */
    private const NETWORK_CURSOR_OPTION = 'encypher_publication_evidence_cursor';

    /** Sentinel for "never", so date lookups stay pure AND queries. */
    public const NEVER = '1970-01-01 00:00:00';

    /** @var array<string,bool> Prefixes whose tables were verified this request. */
    private static array $verified = [];

    public static function events_table(): string
    {
        global $wpdb;
        return $wpdb->prefix . 'encypher_publication_events';
    }

    public static function receipts_table(): string
    {
        global $wpdb;
        return $wpdb->prefix . 'encypher_publication_receipts';
    }

    /**
     * Install or upgrade both tables for the current blog. Idempotent.
     */
    public static function install(): void
    {
        global $wpdb;

        if (! function_exists('dbDelta')) {
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        }

        dbDelta(self::table_definitions($wpdb->get_charset_collate()));
        update_option(self::VERSION_OPTION, self::SCHEMA_VERSION, false);
        self::$verified[$wpdb->prefix] = true;
    }

    /**
     * Boot-time upgrade path: reinstall when the stored schema version differs.
     */
    public static function maybe_install(): void
    {
        if ((int) get_option(self::VERSION_OPTION, 0) !== self::SCHEMA_VERSION) {
            self::install();
        }
    }

    /**
     * One verification per prefix per request, immediately before a write.
     */
    public static function ensure_tables_once(): void
    {
        global $wpdb;
        if (! empty(self::$verified[$wpdb->prefix])) {
            return;
        }
        self::$verified[$wpdb->prefix] = true;
        if (! self::tables_exist()) {
            self::install();
        }
    }

    public static function tables_exist(): bool
    {
        global $wpdb;
        foreach ([self::events_table(), self::receipts_table()] as $table) {
            $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema existence check for a custom table.
            if ((string) $found !== $table) {
                return false;
            }
        }
        return true;
    }

    /**
     * Provision existing network sites in bounded batches.
     *
     * Returns the number of sites provisioned by this batch, or 0 when the
     * network is fully provisioned, so a long network activation resumes from
     * the stored cursor instead of restarting.
     */
    public static function install_network(int $batch = 20): int
    {
        if (! is_multisite()) {
            self::install();
            return 1;
        }

        $batch = max(1, $batch);
        $offset = (int) get_site_option(self::NETWORK_CURSOR_OPTION, 0);
        $site_ids = get_sites(['number' => $batch, 'offset' => $offset, 'fields' => 'ids']);
        if (! $site_ids) {
            delete_site_option(self::NETWORK_CURSOR_OPTION);
            return 0;
        }

        $provisioned = 0;
        foreach ($site_ids as $blog_id) {
            switch_to_blog((int) $blog_id);
            try {
                self::install();
                $provisioned++;
            } finally {
                restore_current_blog();
            }
        }

        update_site_option(self::NETWORK_CURSOR_OPTION, $offset + $provisioned);
        return $provisioned;
    }

    /**
     * Provision a newly created site, always restoring the prior blog.
     *
     * @param mixed $site WP_Site, site-like object, or blog id.
     */
    public static function install_for_new_site($site): void
    {
        $blog_id = is_object($site) ? (int) ($site->blog_id ?? 0) : (int) $site;
        if ($blog_id <= 0) {
            return;
        }

        switch_to_blog($blog_id);
        try {
            self::install();
        } finally {
            restore_current_blog();
        }
    }

    /**
     * Forget the per-request verification cache. Test and recovery seam.
     */
    public static function forget_verified(): void
    {
        self::$verified = [];
    }

    /**
     * @return array<int,string>
     */
    private static function table_definitions(string $charset_collate): array
    {
        return [
            'CREATE TABLE ' . self::events_table() . " (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            event_id varchar(191) NOT NULL,
            chain_seq bigint(20) unsigned NOT NULL DEFAULT 0,
            prev_hash char(64) NOT NULL,
            entry_hash char(64) NOT NULL,
            site_id bigint(20) unsigned NOT NULL DEFAULT 0,
            post_id bigint(20) unsigned NOT NULL DEFAULT 0,
            event_type varchar(48) NOT NULL,
            generation varchar(64) NOT NULL DEFAULT '',
            instance_id varchar(191) NOT NULL DEFAULT '',
            document_id varchar(191) NOT NULL DEFAULT '',
            receipt_token varchar(32) NOT NULL DEFAULT '',
            requested_status varchar(20) NOT NULL DEFAULT '',
            observed_status varchar(20) NOT NULL DEFAULT '',
            actor_kind varchar(10) NOT NULL DEFAULT 'system',
            actor_id bigint(20) unsigned NOT NULL DEFAULT 0,
            reason_code varchar(64) NOT NULL DEFAULT '',
            reason_text text NULL,
            canonical_url varchar(255) NOT NULL DEFAULT '',
            payload_json longtext NULL,
            occurred_at datetime NOT NULL DEFAULT '" . self::NEVER . "',
            PRIMARY KEY  (id),
            UNIQUE KEY event_id (event_id),
            UNIQUE KEY chain_prev (prev_hash),
            KEY post_sequence (post_id,chain_seq),
            KEY type_sequence (event_type,chain_seq),
            KEY chain_order (chain_seq)
        ) {$charset_collate};",
            'CREATE TABLE ' . self::receipts_table() . " (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            receipt_token varchar(32) NOT NULL,
            version int(10) unsigned NOT NULL DEFAULT 1,
            event_id varchar(191) NOT NULL,
            site_id bigint(20) unsigned NOT NULL DEFAULT 0,
            post_id bigint(20) unsigned NOT NULL DEFAULT 0,
            generation varchar(64) NOT NULL DEFAULT '',
            instance_id varchar(191) NOT NULL DEFAULT '',
            document_id varchar(191) NOT NULL DEFAULT '',
            canonical_input_digest varchar(191) NOT NULL DEFAULT '',
            publisher varchar(255) NOT NULL DEFAULT '',
            canonical_url varchar(255) NOT NULL DEFAULT '',
            integrity varchar(40) NOT NULL DEFAULT '',
            availability_policy varchar(32) NOT NULL DEFAULT 'post_public_only',
            signed_at varchar(40) NOT NULL DEFAULT '',
            live_verified_at varchar(40) NOT NULL DEFAULT '',
            superseded_at varchar(40) NOT NULL DEFAULT '',
            error_text text NULL,
            recorded_at datetime NOT NULL DEFAULT '" . self::NEVER . "',
            PRIMARY KEY  (id),
            UNIQUE KEY receipt_event (event_id),
            UNIQUE KEY token_version (receipt_token,version),
            KEY post_token (post_id,receipt_token),
            KEY post_order (post_id,id)
        ) {$charset_collate};",
        ];
    }
}
