<?php
namespace EncypherProvenance;

use WP_Error;

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

/**
 * Projects successfully signed WordPress posts into the generic publisher
 * provenance registry. Delivery is asynchronous and fail-open for editorial
 * publishing. Retries reuse one stable idempotency key.
 */
final class PublisherPublication
{
    private const RETRY_HOOK = 'encypher_retry_publisher_publication';
    private const MAX_ATTEMPTS = 8;
    private const JOB_META = '_encypher_registry_generation_job';

    /** Every delivery state this class can store. Anything else is not_queued. */
    private const DELIVERY_STATES = ['pending', 'retry', 'published', 'failed', 'cancelled'];

    /** Delivery states an operator may resend from. */
    private const RETRYABLE_STATES = ['retry', 'failed', 'cancelled'];

    public static function register_hooks(): void
    {
        add_action('added_post_meta', [self::class, 'on_post_meta'], 10, 4);
        add_action('updated_post_meta', [self::class, 'on_post_meta'], 10, 4);
        add_action(self::RETRY_HOOK, [self::class, 'publish'], 10, 2);
    }

    /** Queue only after a complete text generation reaches its commit point. */
    public static function on_post_meta(int $meta_id, int $post_id, string $meta_key, $meta_value): void
    {
        unset($meta_id);
        if ('_encypher_provenance_status' === $meta_key && 'c2pa_protected' === $meta_value) {
            self::queue($post_id);
        }
    }

    public static function queue(int $post_id): void
    {
        if ($post_id < 1 || wp_is_post_revision($post_id)) {
            return;
        }

        self::with_post_lock($post_id, static function () use ($post_id): void {
            self::queue_locked($post_id);
        });
    }

    private static function queue_locked(int $post_id): void
    {
        $signed_content = (string) get_post_meta($post_id, '_encypher_signed_text', true);
        $content_hash = strtolower(trim((string) get_post_meta($post_id, '_encypher_provenance_cached_content_hash', true)));
        $generation = trim((string) get_post_meta($post_id, '_encypher_provenance_generation', true));
        $revision = trim((string) get_post_meta($post_id, '_encypher_manifest_id', true));
        if ('' === $revision) {
            $revision = trim((string) get_post_meta($post_id, '_encypher_provenance_instance_id', true));
        }
        if ('' === $generation) {
            $generation = $revision;
        }
        if ('' === $generation) {
            $generation = $content_hash;
        }

        $post = get_post($post_id);
        $canonical_url = get_permalink($post_id);
        $domain = is_string($canonical_url) ? wp_parse_url($canonical_url, PHP_URL_HOST) : '';
        if (
            ! $post
            || 'publish' !== (string) $post->post_status
            || ! is_post_publicly_viewable($post)
            || '' !== (string) $post->post_password
            || '' === $signed_content
            || 1 !== preg_match('/\A[0-9a-f]{32}\z/', $content_hash)
            || ! hash_equals($content_hash, md5((string) $post->post_content))
            || '' === $generation
            || ! is_string($canonical_url)
            || '' === $canonical_url
            || ! is_string($domain)
            || '' === $domain
        ) {
            delete_post_meta($post_id, self::JOB_META);
            update_post_meta($post_id, '_encypher_registry_status', 'cancelled');
            update_post_meta($post_id, '_encypher_registry_last_error', 'The signed post is not eligible for public publication.');
            return;
        }

        $allowed_declarations = ['human_declared', 'ai_assisted_declared', 'ai_generated_declared', 'undeclared'];
        $declaration = (string) get_post_meta($post_id, '_encypher_content_declaration', true);
        if (! in_array($declaration, $allowed_declarations, true)) {
            $declaration = 'human_declared';
        }
        $payload = [
            'domain' => strtolower(rtrim($domain, '.')),
            'canonical_url' => $canonical_url,
            'content_type' => 'text/html',
            'content' => $signed_content,
            'declaration' => $declaration,
            'publish' => true,
            'visibility' => 'public',
        ];
        $settings = get_option('encypher_provenance_settings', []);
        $identity_id = is_array($settings) ? trim((string) ($settings['publisher_identity_id'] ?? '')) : '';
        if ('' !== $identity_id) {
            $payload['publisher_identity_id'] = $identity_id;
        }
        $credential_fingerprint = self::credential_fingerprint($settings);

        $delivery_id = 'wordpress-' . hash('sha256', home_url('/') . '|' . $post_id . '|' . $generation);
        $job = [
            'delivery_id' => $delivery_id,
            'generation' => $generation,
            'revision' => $revision,
            'content_hash' => $content_hash,
            'signed_content_hash' => hash('sha256', $signed_content),
            'post_status' => (string) $post->post_status,
            'post_password' => (string) $post->post_password,
            'publicly_viewable' => true,
            'canonical_url' => $canonical_url,
            'provenance_status' => (string) get_post_meta($post_id, '_encypher_provenance_status', true),
            'payload' => $payload,
            'credential_fingerprint' => $credential_fingerprint,
        ];
        update_post_meta($post_id, self::JOB_META, $job);
        update_post_meta($post_id, '_encypher_registry_delivery_id', $delivery_id);
        update_post_meta($post_id, '_encypher_registry_status', 'pending');
        update_post_meta($post_id, '_encypher_registry_queued_at', gmdate('c'));
        delete_post_meta($post_id, '_encypher_registry_last_error');
        delete_post_meta($post_id, '_encypher_registry_attempts');
        // No attempt has been made for this delivery yet, so the operator must
        // not see a previous delivery's attempt time against it.
        delete_post_meta($post_id, '_encypher_registry_last_attempt_at');
        self::schedule($post_id, $delivery_id, 1);
    }

    /** WordPress cron callback. Never publishes mutable live post content. */
    public static function publish(int $post_id, string $delivery_id = ''): void
    {
        $job = get_post_meta($post_id, self::JOB_META, true);
        if (! is_array($job) || empty($job['delivery_id'])) {
            return;
        }
        $current_delivery_id = (string) $job['delivery_id'];
        if ('' !== $delivery_id && ! hash_equals($current_delivery_id, $delivery_id)) {
            return;
        }
        $delivery_id = $current_delivery_id;
        if (! self::generation_is_current($post_id, $job)) {
            self::cancel_if_current($post_id, $delivery_id, 'Signing generation was modified or superseded before publication.');
            return;
        }

        $payload = $job['payload'] ?? null;
        if (! is_array($payload) || empty($payload['content'])) {
            self::cancel_if_current($post_id, $delivery_id, 'The queued signed payload was unavailable.');
            return;
        }

        // Revalidate the immutable public snapshot at the last possible point
        // before bytes leave WordPress.
        if (! self::generation_is_current($post_id, $job)) {
            self::cancel_if_current($post_id, $delivery_id, 'Post publication state, visibility, password, URL, or signing generation changed.');
            return;
        }

        // Read the settings again immediately before sending. A configuration
        // change must fence the queued credentials without making a request.
        $settings = get_option('encypher_provenance_settings', []);
        if (! self::credentials_are_current($job, $settings)) {
            self::cancel_if_current($post_id, $delivery_id, 'Publisher provenance API credentials changed after this publication was queued.');
            return;
        }
        $api_base = is_array($settings) ? rtrim((string) ($settings['api_base_url'] ?? ''), '/') : '';
        $api_key = is_array($settings) ? trim((string) ($settings['api_key'] ?? '')) : '';
        if ('' === $api_base || '' === $api_key) {
            self::failed_attempt($post_id, $delivery_id, $job, 'Publisher provenance API configuration is incomplete.');
            return;
        }

        // One attempt is about to leave WordPress. Record it before the call so
        // an operator screen shows the attempt even while it is in flight.
        update_post_meta($post_id, '_encypher_registry_last_attempt_at', gmdate('c'));
        $response = ApiUrlGuard::guarded_remote_post($api_base . '/provenance/publications', [
            'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- background registry projection, never a front-end request.
            'headers' => [
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'application/json',
                'Idempotency-Key' => $delivery_id,
            ],
            'body' => wp_json_encode($payload),
        ]);
        if (is_wp_error($response)) {
            self::failed_attempt($post_id, $delivery_id, $job, $response->get_error_message());
            return;
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
        $body = is_array($decoded) && isset($decoded['data']) && is_array($decoded['data']) ? $decoded['data'] : $decoded;
        if ($status < 200 || $status >= 300 || ! is_array($body)) {
            self::failed_attempt($post_id, $delivery_id, $job, 'Publisher provenance API rejected the publication.');
            return;
        }

        // Queue replacement and response commit use the same per-post lock.
        // Response identifiers land first; published is the commit marker.
        self::with_post_lock($post_id, static function () use ($post_id, $delivery_id, $job, $body): void {
            if (! self::job_is_current($post_id, $delivery_id) || ! self::generation_is_current($post_id, $job)) {
                return;
            }
            $settings = get_option('encypher_provenance_settings', []);
            if (! self::credentials_are_current($job, $settings)) {
                update_post_meta($post_id, '_encypher_registry_status', 'cancelled');
                update_post_meta(
                    $post_id,
                    '_encypher_registry_last_error',
                    'Publisher provenance API credentials changed after this publication was queued.'
                );
                return;
            }
            if (array_key_exists('publication_id', $body) && '' !== (string) $body['publication_id']) {
                update_post_meta($post_id, '_encypher_registry_publication_id', sanitize_text_field((string) $body['publication_id']));
            } else {
                delete_post_meta($post_id, '_encypher_registry_publication_id');
            }
            if (array_key_exists('evidence_id', $body) && '' !== (string) $body['evidence_id']) {
                update_post_meta($post_id, '_encypher_registry_evidence_id', sanitize_text_field((string) $body['evidence_id']));
            } else {
                delete_post_meta($post_id, '_encypher_registry_evidence_id');
            }
            // Only the exact URLs the registry returned are stored. A guessed
            // dashboard host would be worse than no link.
            foreach (['dashboard_url', 'receipt_url'] as $field) {
                $url = isset($body[$field]) ? self::external_url((string) $body[$field]) : '';
                if ('' !== $url) {
                    update_post_meta($post_id, '_encypher_registry_' . $field, $url);
                } else {
                    delete_post_meta($post_id, '_encypher_registry_' . $field);
                }
            }
            delete_post_meta($post_id, '_encypher_registry_last_error');
            delete_post_meta($post_id, '_encypher_registry_attempts');
            update_post_meta($post_id, '_encypher_registry_status', 'published');
        });
    }

    /**
     * The registry delivery read model for one post.
     *
     * Delivery is its own dimension: a failed registry projection never claims
     * the post is unsigned, and a signed post never claims a delivery that has
     * not happened. `not_queued` means exactly that, never success.
     *
     * @return array<string,mixed>
     */
    public static function projection(int $post_id): array
    {
        $status = (string) get_post_meta($post_id, '_encypher_registry_status', true);
        $state = in_array($status, self::DELIVERY_STATES, true) ? $status : 'not_queued';
        $delivery_id = (string) get_post_meta($post_id, '_encypher_registry_delivery_id', true);
        $next = '' !== $delivery_id
            ? wp_next_scheduled(self::RETRY_HOOK, [$post_id, $delivery_id])
            : false;

        return [
            'delivery_state' => $state,
            'delivery_id' => $delivery_id,
            'publication_id' => (string) get_post_meta($post_id, '_encypher_registry_publication_id', true),
            'evidence_id' => (string) get_post_meta($post_id, '_encypher_registry_evidence_id', true),
            'attempts' => (int) get_post_meta($post_id, '_encypher_registry_attempts', true),
            'max_attempts' => self::MAX_ATTEMPTS,
            'queued_at' => (string) get_post_meta($post_id, '_encypher_registry_queued_at', true),
            'last_attempt_at' => (string) get_post_meta($post_id, '_encypher_registry_last_attempt_at', true),
            // Read from the scheduler itself, so the operator view can never
            // contradict when the retry will actually run.
            'next_attempt_at' => is_numeric($next) ? gmdate('c', (int) $next) : '',
            'error' => (string) get_post_meta($post_id, '_encypher_registry_last_error', true),
            'dashboard_url' => (string) get_post_meta($post_id, '_encypher_registry_dashboard_url', true),
            'receipt_url' => (string) get_post_meta($post_id, '_encypher_registry_receipt_url', true),
            'retryable' => in_array($state, self::RETRYABLE_STATES, true),
            'terminal' => in_array($state, ['published', 'failed', 'cancelled'], true),
        ];
    }

    /**
     * Operator-initiated redelivery of a failed or cancelled publication.
     *
     * The delivery id is derived from the signing generation, so a retry of the
     * same generation reuses one idempotency key and the registry cannot record
     * a duplicate publication. Eligibility is revalidated from scratch, so a
     * post that stopped being publicly viewable is refused rather than resent.
     *
     * @return array<string,mixed>|WP_Error
     */
    public static function retry(int $post_id, int $actor_id = 0)
    {
        if ($post_id < 1 || ! get_post($post_id)) {
            return new WP_Error(
                'registry_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 || (function_exists('user_can') && ! user_can($actor_id, 'edit_post', $post_id))) {
            return new WP_Error(
                'registry_retry_forbidden',
                __('Retrying a publisher registry delivery requires edit authority for this post.', 'encypher-provenance')
            );
        }

        $current = self::projection($post_id);
        if (! $current['retryable']) {
            return new WP_Error(
                'registry_not_retryable',
                __('This publication has no failed registry delivery to retry.', 'encypher-provenance')
            );
        }

        // Drop the pending backoff tick so the operator's retry runs now
        // instead of waiting out the remaining exponential delay.
        if ('' !== (string) $current['delivery_id']) {
            wp_clear_scheduled_hook(self::RETRY_HOOK, [$post_id, (string) $current['delivery_id']]);
        }
        self::queue($post_id);

        $projection = self::projection($post_id);
        if ('pending' !== $projection['delivery_state']) {
            return new WP_Error(
                'registry_retry_ineligible',
                '' !== (string) $projection['error']
                    ? (string) $projection['error']
                    : __('The signed post is not eligible for registry publication.', 'encypher-provenance')
            );
        }

        return $projection;
    }

    /**
     * Accept only an absolute http(s) URL the registry itself returned.
     */
    private static function external_url(string $url): string
    {
        $url = trim($url);
        if ('' === $url) {
            return '';
        }
        $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
        $host = (string) wp_parse_url($url, PHP_URL_HOST);
        if (! in_array($scheme, ['http', 'https'], true) || '' === $host) {
            return '';
        }

        return esc_url_raw($url);
    }

    /** @param array<string,mixed> $job */
    private static function generation_is_current(int $post_id, array $job): bool
    {
        if ('c2pa_protected' !== (string) get_post_meta($post_id, '_encypher_provenance_status', true)) {
            return false;
        }
        $generation = (string) get_post_meta($post_id, '_encypher_provenance_generation', true);
        if ('' === $generation) {
            $generation = (string) get_post_meta($post_id, '_encypher_manifest_id', true);
        }
        if ('' === $generation) {
            $generation = (string) get_post_meta($post_id, '_encypher_provenance_cached_content_hash', true);
        }
        $revision = (string) get_post_meta($post_id, '_encypher_manifest_id', true);
        if ('' === $revision) {
            $revision = (string) get_post_meta($post_id, '_encypher_provenance_instance_id', true);
        }
        $post = get_post($post_id);
        $signed_content = (string) get_post_meta($post_id, '_encypher_signed_text', true);
        $canonical_url = get_permalink($post_id);
        return $post
            && 'publish' === (string) $post->post_status
            && is_post_publicly_viewable($post)
            && '' === (string) $post->post_password
            && 'publish' === (string) ($job['post_status'] ?? '')
            && true === ($job['publicly_viewable'] ?? false)
            && '' === (string) ($job['post_password'] ?? '')
            && is_string($canonical_url)
            && hash_equals((string) ($job['canonical_url'] ?? ''), $canonical_url)
            && hash_equals((string) ($job['provenance_status'] ?? ''), (string) get_post_meta($post_id, '_encypher_provenance_status', true))
            && hash_equals((string) ($job['generation'] ?? ''), $generation)
            && hash_equals((string) ($job['revision'] ?? ''), $revision)
            && hash_equals((string) ($job['content_hash'] ?? ''), md5((string) $post->post_content))
            && hash_equals((string) ($job['signed_content_hash'] ?? ''), hash('sha256', $signed_content));
    }

    /** @param mixed $settings */
    private static function credential_fingerprint($settings): string
    {
        $api_base = is_array($settings) ? rtrim((string) ($settings['api_base_url'] ?? ''), '/') : '';
        $api_key = is_array($settings) ? trim((string) ($settings['api_key'] ?? '')) : '';

        return hash('sha256', $api_base . "\0" . hash('sha256', $api_key));
    }

    /**
     * @param array<string,mixed> $job
     * @param mixed               $settings
     */
    private static function credentials_are_current(array $job, $settings): bool
    {
        $queued_fingerprint = (string) ($job['credential_fingerprint'] ?? '');

        return '' !== $queued_fingerprint
            && hash_equals($queued_fingerprint, self::credential_fingerprint($settings));
    }

    private static function job_is_current(int $post_id, string $delivery_id): bool
    {
        $job = get_post_meta($post_id, self::JOB_META, true);
        return is_array($job)
            && isset($job['delivery_id'])
            && hash_equals($delivery_id, (string) $job['delivery_id']);
    }

    private static function cancel_if_current(int $post_id, string $delivery_id, string $message): void
    {
        self::with_post_lock($post_id, static function () use ($post_id, $delivery_id, $message): void {
            if (! self::job_is_current($post_id, $delivery_id)) {
                return;
            }
            update_post_meta($post_id, '_encypher_registry_status', 'cancelled');
            update_post_meta($post_id, '_encypher_registry_last_error', sanitize_text_field($message));
        });
    }

    /** @param array<string,mixed> $job */
    private static function failed_attempt(int $post_id, string $delivery_id, array $job, string $message): void
    {
        self::with_post_lock($post_id, static function () use ($post_id, $delivery_id, $job, $message): void {
            if (! self::job_is_current($post_id, $delivery_id)) {
                return;
            }
            $settings = get_option('encypher_provenance_settings', []);
            if (! self::credentials_are_current($job, $settings)) {
                update_post_meta($post_id, '_encypher_registry_status', 'cancelled');
                update_post_meta(
                    $post_id,
                    '_encypher_registry_last_error',
                    'Publisher provenance API credentials changed after this publication was queued.'
                );
                return;
            }
            $attempts = (int) get_post_meta($post_id, '_encypher_registry_attempts', true) + 1;
            update_post_meta($post_id, '_encypher_registry_attempts', $attempts);
            update_post_meta($post_id, '_encypher_registry_last_error', sanitize_text_field($message));
            if ($attempts >= self::MAX_ATTEMPTS) {
                update_post_meta($post_id, '_encypher_registry_status', 'failed');
                return;
            }
            update_post_meta($post_id, '_encypher_registry_status', 'retry');
            self::schedule($post_id, $delivery_id, min(3600, 60 * (2 ** min($attempts, 6))));
        });
    }

    /**
     * Serialize queue replacement and response finalization for one post.
     * MySQL advisory locks are connection-owned and released automatically if
     * a PHP worker dies, so a crashed worker cannot strand the post.
     */
    private static function with_post_lock(int $post_id, callable $callback): void
    {
        global $wpdb;
        if (! isset($wpdb) || ! is_object($wpdb) || ! method_exists($wpdb, 'get_var') || ! method_exists($wpdb, 'prepare')) {
            $callback();
            return;
        }

        // Keep below MySQL's 64-byte advisory-lock name limit.
        $lock_name = 'encypher:publisher:' . substr(hash('sha256', home_url('/') . '|' . $post_id), 0, 40);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Advisory locks are synchronization primitives, not cacheable data reads.
        $acquired = '1' === (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 10));
        if (! $acquired) {
            return;
        }
        try {
            $callback();
        } finally {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Advisory locks are synchronization primitives, not cacheable data reads.
            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
        }
    }

    private static function schedule(int $post_id, string $delivery_id, int $delay): void
    {
        $args = [$post_id, $delivery_id];
        if (! wp_next_scheduled(self::RETRY_HOOK, $args)) {
            wp_schedule_single_event(time() + $delay, self::RETRY_HOOK, $args);
        }
    }
}
