<?php
/**
 * WS2: Cryptographic edit history.
 *
 * Append-only log of provenance-affecting editor actions (create, edit, sign,
 * re-sign, block add/remove, rights change). The AUTHORITATIVE tamper-evident
 * record is the C2PA manifest chain (c2pa.created -> c2pa.edited with
 * prev-instance ingredient refs) plus the off-box API transparency log; this
 * local table is an UNSIGNED query/index convenience keyed by block UUID and
 * post id, so editors get a fast local history without trusting it as the
 * security root. Per the signed-off plan there are deliberately NO local row
 * signatures: if the WP host is compromised the attacker owns these rows and
 * any local key, so cryptographic tamper-evidence lives off-box.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

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

class EditLog
{
    const TABLE = 'encypher_edit_log';

    /**
     * Fully-qualified table name.
     */
    public static function table_name(): string
    {
        global $wpdb;
        return $wpdb->prefix . self::TABLE;
    }

    /**
     * Create the edit-log table. Called from the plugin activation hook.
     */
    public static function create_table(): void
    {
        global $wpdb;
        $table = self::table_name();
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE {$table} (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            post_id BIGINT UNSIGNED NOT NULL,
            block_id VARCHAR(64) NULL,
            action VARCHAR(64) NOT NULL,
            actor_id BIGINT UNSIGNED NULL,
            content_hash CHAR(64) NULL,
            manifest_id VARCHAR(191) NULL,
            prev_manifest_id VARCHAR(191) NULL,
            instance_id VARCHAR(191) NULL,
            mirrored TINYINT(1) NOT NULL DEFAULT 0,
            created_at DATETIME NOT NULL,
            PRIMARY KEY  (id),
            KEY post_id (post_id),
            KEY block_id (block_id),
            KEY created_at (created_at),
            UNIQUE KEY post_manifest (post_id, manifest_id)
        ) {$charset_collate};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
    }

    /**
     * Append an edit-log entry and (best-effort) mirror it to the off-box
     * transparency log. Returns the inserted row id, or 0 on failure.
     *
     * @param array $entry {
     *   @type int    $post_id          Required.
     *   @type string $action           Required (e.g. c2pa.created, c2pa.edited, rights.changed, block.added).
     *   @type string $block_id         Optional persisted block UUID.
     *   @type string $content_hash     Optional canonical-text SHA-256.
     *   @type string $manifest_id      Optional.
     *   @type string $prev_manifest_id Optional.
     *   @type string $instance_id      Optional.
     * }
     */
    public static function append(array $entry): int
    {
        global $wpdb;

        $post_id = isset($entry['post_id']) ? (int) $entry['post_id'] : 0;
        $action  = isset($entry['action']) ? sanitize_text_field((string) $entry['action']) : '';
        if ($post_id <= 0 || '' === $action) {
            return 0;
        }

        // Idempotency guard: the same signing operation (identified by its
        // manifest/document id) must not produce duplicate rows when the sign
        // path is re-entered (e.g. REST save + deferred shutdown auto-sign both
        // firing for one publish). A genuine RE-sign produces a new manifest_id
        // and is therefore still recorded as a new history entry.
        $manifest_id = isset($entry['manifest_id']) ? sanitize_text_field((string) $entry['manifest_id']) : '';
        if ('' !== $manifest_id) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- idempotency check on the custom encypher_edit_log table immediately before insert; a fresh row, not cacheable.
            $existing_id = (int) $wpdb->get_var(
                $wpdb->prepare(
                    'SELECT id FROM ' . self::table_name() . ' WHERE post_id = %d AND manifest_id = %s LIMIT 1', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from self::table_name() ($wpdb->prefix + constant); all values are prepared.
                    $post_id,
                    $manifest_id
                )
            );
            if ($existing_id > 0) {
                return $existing_id;
            }
        }
        $row = [
            'post_id'          => $post_id,
            'block_id'         => isset($entry['block_id']) ? sanitize_text_field((string) $entry['block_id']) : null,
            'action'           => $action,
            'actor_id'         => get_current_user_id() ?: null,
            'content_hash'     => isset($entry['content_hash']) ? sanitize_text_field((string) $entry['content_hash']) : null,
            'manifest_id'      => isset($entry['manifest_id']) ? sanitize_text_field((string) $entry['manifest_id']) : null,
            'prev_manifest_id' => isset($entry['prev_manifest_id']) ? sanitize_text_field((string) $entry['prev_manifest_id']) : null,
            'instance_id'      => isset($entry['instance_id']) ? sanitize_text_field((string) $entry['instance_id']) : null,
            'mirrored'         => 0,
            'created_at'       => current_time('mysql', true),
        ];

        $ok = $wpdb->insert(self::table_name(), $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- append-only insert into the custom encypher_edit_log table.
        if (false === $ok) {
            return 0;
        }
        $row_id = (int) $wpdb->insert_id;

        // Best-effort off-box mirror; never break signing if it fails.
        if (self::mirror_to_transparency_log($row)) {
            $wpdb->update(self::table_name(), ['mirrored' => 1], ['id' => $row_id]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- flips the mirrored flag on the row just inserted into the custom encypher_edit_log table.
        }

        return $row_id;
    }

    /**
     * Build the JSON body for POST /integrations/wordpress/edit-log from a
     * stored edit-log row. Pure (no WP I/O); public so the contract fixture
     * generator emits exactly the payload the mirror sends.
     *
     * @param array  $row        Edit-log row as built by append().
     * @param string $install_id This site's install id ('' when unregistered).
     */
    public static function build_mirror_payload(array $row, string $install_id): array
    {
        return [
            'install_id'       => $install_id,
            'post_id'          => $row['post_id'] ?? 0,
            'block_id'         => $row['block_id'] ?? null,
            'action'           => $row['action'] ?? '',
            'content_hash'     => $row['content_hash'] ?? null,
            'manifest_id'      => $row['manifest_id'] ?? null,
            'prev_manifest_id' => $row['prev_manifest_id'] ?? null,
            'instance_id'      => $row['instance_id'] ?? null,
            'created_at'       => $row['created_at'] ?? '',
        ];
    }

    /**
     * Mirror an edit-log entry to the Enterprise API transparency log. The API
     * keys it by instance_id alongside content_references, making the off-box
     * log the authoritative tamper-evident record. Returns true on a 2xx.
     *
     * Graceful degradation (deliberate, single-shot): this runs exactly once
     * per append(), with an 8 second timeout, and treats every failure mode
     * identically (transport error, a 404 from a server that does not expose
     * the edit-log endpoint yet, any other non-2xx): the row keeps
     * mirrored = 0 and the method returns false. There is no retry queue, no
     * cron re-mirror, and no admin-facing error state, so a pre-deploy server
     * sees at most one request per signing action.
     */
    private static function mirror_to_transparency_log(array $row): bool
    {
        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            return false;
        }
        $api_base = isset($settings['api_base_url']) ? rtrim((string) $settings['api_base_url'], '/') : '';
        $api_key  = isset($settings['api_key']) ? (string) $settings['api_key'] : '';
        $install  = isset($settings['install_id']) ? (string) $settings['install_id'] : '';
        if ('' === $api_base || '' === $api_key) {
            return false;
        }

        $response = ApiUrlGuard::guarded_remote_post($api_base . '/integrations/wordpress/edit-log', [
            'timeout' => 8, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- best-effort off-box edit-log mirror during signing; not a front-end request.
            'headers' => [
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type'  => 'application/json',
            ],
            'body' => wp_json_encode(self::build_mirror_payload($row, $install)),
        ]);

        if (is_wp_error($response)) {
            return false;
        }
        $code = (int) wp_remote_retrieve_response_code($response);
        return $code >= 200 && $code < 300;
    }

    /**
     * Return the edit history for a post (most recent first). Query/index
     * convenience for the editor UI; NOT a security root.
     *
     * @return array<int,array<string,mixed>>
     */
    public static function history_for_post(int $post_id, int $limit = 100): array
    {
        global $wpdb;
        $table = self::table_name();
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- editor-UI history read from the custom encypher_edit_log table; row set changes on every edit, admin-only panel.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, block_id, action, actor_id, content_hash, manifest_id, prev_manifest_id, instance_id, mirrored, created_at
                 FROM {$table} WHERE post_id = %d ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is self::table_name() ($wpdb->prefix + constant); all values are prepared.
                $post_id,
                $limit
            ),
            ARRAY_A
        );
        return is_array($rows) ? $rows : [];
    }
}
