<?php
namespace EncypherProvenance;

use WP_Error;
use WP_Query;

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

/**
 * Site Health integration: end-to-end provenance integrity self-test.
 *
 * The render pipeline returns the signed snapshot at final the_content
 * priority, but later output-buffer plugins, page caches, and theme rendering
 * can still mutate what readers receive. The health check fetches the latest
 * signed post, isolates the exact canonical signed article sequence from the
 * surrounding page chrome when it survives intact, and round-trips that
 * sequence through the configured /verify API. If the exact sequence is gone,
 * it falls back to verifying the full rendered page so the verifier can still
 * accept supported normalizations or reject marker/content damage.
 */
class SiteHealth
{
    /**
     * Transient key caching the last test result, so Site Health refreshes
     * (and the async grade recalculation) don't hammer /verify.
     */
    private const RESULT_TRANSIENT = 'encypher_site_health_integrity';

    /**
     * Provenance statuses under which the render pipeline injects the signed
     * snapshot. MUST mirror the staleness guard in
     * Frontend::inject_provenance_markers() — a post outside these statuses
     * serves live clean content, so there is nothing byte-exact to check.
     */
    private const SIGNED_STATUSES = ['c2pa_protected', 'c2pa_verified', 'signed', 'verified'];

    /**
     * Register hooks for the Site Health screen.
     */
    public function register_hooks(): void
    {
        add_filter('site_status_tests', [$this, 'register_tests']);
    }

    /**
     * Register the provenance integrity test with Site Health.
     *
     * @param array $tests Site Health tests, keyed by direct/async.
     * @return array Tests with the integrity check added.
     */
    public function register_tests(array $tests): array
    {
        $tests['direct']['encypher_provenance_integrity'] = [
            'label' => __('Encypher provenance integrity', 'encypher-provenance'),
            'test'  => [$this, 'run_integrity_test'],
        ];

        $tests['direct']['encypher_media_pipeline'] = [
            'label' => __('Encypher media pipeline', 'encypher-provenance'),
            'test'  => [$this, 'run_media_pipeline_test'],
        ];

        $tests['direct']['encypher_protection_paths'] = [
            'label' => __('Encypher protection paths', 'encypher-provenance'),
            'test'  => [$this, 'run_protection_paths_test'],
        ];

        return $tests;
    }

    /**
     * Summarize the frozen reader-verdict counters without treating transport
     * failures or changed content as successful checks.
     *
     * @return array<string,mixed>
     */
    public static function reader_path_summary(): array
    {
        $keys = ['trusted', 'valid_untrusted', 'changed', 'unavailable', 'not_present'];
        $normalize = static function ($raw) use ($keys): array {
            $raw = is_array($raw) ? $raw : [];
            $out = [];
            foreach ($keys as $key) {
                $out[$key] = max(0, (int) ($raw[$key] ?? 0));
            }
            return $out;
        };

        $lifetime_raw = get_option('encypher_verify_outcomes', null);
        $lifetime = $normalize($lifetime_raw);
        if (! is_array($lifetime_raw)) {
            // Migration: legacy counters contain only confirmed successes.
            $lifetime['trusted'] = max(0, (int) get_option('encypher_verify_hits', 0));
        }

        $day = gmdate('Y-m-d');
        $daily_raw = get_option('encypher_verify_outcomes_' . $day, null);
        $daily = $normalize($daily_raw);
        if (! is_array($daily_raw)) {
            $legacy_map = get_option('encypher_verify_hits_daily', []);
            $legacy_map = is_array($legacy_map) ? $legacy_map : [];
            $daily['trusted'] = max(0, (int) get_option('encypher_verify_hits_day_' . gmdate('Ymd'), 0))
                + max(0, (int) ($legacy_map[$day] ?? 0));
        }

        $success = $daily['trusted'] + $daily['valid_untrusted'];
        $failed = $daily['changed'] + $daily['not_present'];
        $unavailable = $daily['unavailable'];
        $status = ($failed > 0 || $unavailable > 0)
            ? 'broken'
            : ($success > 0 ? 'healthy' : 'unknown');

        return [
            'status' => $status,
            'success' => $success,
            'failed' => $failed,
            'unavailable' => $unavailable,
            'daily' => $daily,
            'lifetime' => $lifetime,
            'lifetime_success' => $lifetime['trusted'] + $lifetime['valid_untrusted'],
            'lifetime_failed' => $lifetime['changed'] + $lifetime['not_present'],
            'lifetime_unavailable' => $lifetime['unavailable'],
        ];
    }

    /**
     * Report API reachability, protected publishing, queue delivery, and the
     * reader path as separate facts. A healthy API can never mask a broken
     * protection or reader path.
     *
     * @return array Site Health result array.
     */
    public function run_protection_paths_test(): array
    {
        $settings = get_option('encypher_provenance_settings', []);
        $settings = is_array($settings) ? $settings : [];
        $api_healthy = 'connected' === (string) ($settings['connection_last_status'] ?? 'unknown');
        $protected = ! array_key_exists('protected_publish_enabled', $settings)
            || ! empty($settings['protected_publish_enabled']);
        $queue = class_exists(Admin::class) ? Admin::detect_queue_stall() : ['stalled' => true];
        $reader = self::reader_path_summary();
        $broken = ! $protected
            || ! empty($queue['stalled'])
            || (int) ($queue['failed_count'] ?? 0) > 0
            || 'broken' === $reader['status'];

        $description = '<ul>';
        $description .= '<li>' . esc_html(sprintf(
            __('API connection: %s.', 'encypher-provenance'),
            $api_healthy ? __('healthy', 'encypher-provenance') : __('check required', 'encypher-provenance')
        )) . '</li>';
        $description .= '<li>' . esc_html(sprintf(
            __('Protected publishing: %s.', 'encypher-provenance'),
            $protected ? __('on (fail closed)', 'encypher-provenance') : __('off (fail open)', 'encypher-provenance')
        )) . '</li>';
        $description .= '<li>' . esc_html(sprintf(
            __('Background queue: %1$s pending, %2$s failed.', 'encypher-provenance'),
            number_format_i18n((int) ($queue['pending_count'] ?? 0)),
            number_format_i18n((int) ($queue['failed_count'] ?? 0))
        )) . '</li>';
        $description .= '<li>' . esc_html(sprintf(
            __('Reader checks today: %1$s successful, %2$s failed, %3$s unavailable.', 'encypher-provenance'),
            number_format_i18n((int) $reader['success']),
            number_format_i18n((int) $reader['failed']),
            number_format_i18n((int) $reader['unavailable'])
        )) . '</li>';
        $description .= '</ul>';

        if ($broken) {
            $description .= '<p>' . esc_html__('Protection is not healthy. Turn on protected publishing, process queued work, and retry failed or unavailable reader checks before treating this site as protected.', 'encypher-provenance') . '</p>';
        } elseif (! $api_healthy) {
            $description .= '<p>' . esc_html__('The protection settings are intact, but the saved API connection needs a successful check.', 'encypher-provenance') . '</p>';
        }

        return [
            'label' => $broken
                ? __('Encypher protection needs attention', 'encypher-provenance')
                : ($api_healthy
                    ? __('Encypher protection paths are healthy', 'encypher-provenance')
                    : __('Encypher API connection needs a check', 'encypher-provenance')),
            'status' => $broken ? 'critical' : ($api_healthy ? 'good' : 'recommended'),
            'badge' => [
                'label' => __('Encypher', 'encypher-provenance'),
                'color' => $broken ? 'red' : ($api_healthy ? 'blue' : 'orange'),
            ],
            'description' => $description,
            'actions' => '<p><a href="' . esc_url(admin_url('admin.php?page=encypher')) . '" class="button">'
                . esc_html__('Open Encypher Setup & Health', 'encypher-provenance') . '</a></p>',
            'test' => 'encypher_protection_paths',
        ];
    }

    /**
     * Run the integrity test, serving a cached verdict when fresh.
     *
     * @return array Site Health result array.
     */
    public function run_integrity_test(): array
    {
        $cached = get_transient(self::RESULT_TRANSIENT);
        if (is_array($cached) && isset($cached['status'])) {
            return $cached;
        }

        $result = $this->perform_integrity_test();
        set_transient(self::RESULT_TRANSIENT, $result, 5 * MINUTE_IN_SECONDS);

        return $result;
    }

    /**
     * Informational Site Health report on the media provenance pipeline: the
     * retroactive scan cursor state, how many attachments' last Encypher sign
     * attempt errored, and how many files were skipped as offloaded/missing
     * during the scan. Purely informational (never fails the grade); local
     * reads only.
     *
     * @return array Site Health result array.
     */
    public function run_media_pipeline_test(): array
    {
        $state = class_exists(MediaScan::class) ? MediaScan::get_state() : [
            'status'            => 'idle',
            'total'             => 0,
            'scanned'           => 0,
            'found'             => 0,
            'skipped_offloaded' => 0,
        ];

        $query = new WP_Query([
            'post_type'      => 'attachment',
            'post_status'    => 'inherit',
            'posts_per_page' => 1,
            'fields'         => 'ids',
            'no_found_rows'  => false,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Site Health runs only on demand in wp-admin and needs an exact error count.
            'meta_query'     => [
                ['key' => '_encypher_last_sign_error', 'compare' => 'EXISTS'],
            ],
        ]);
        $sign_errors = (int) $query->found_posts;

        $status_labels = [
            'idle'      => __('not started', 'encypher-provenance'),
            'running'   => __('in progress', 'encypher-provenance'),
            'complete'  => __('complete', 'encypher-provenance'),
            'cancelled' => __('cancelled', 'encypher-provenance'),
        ];
        $scan_status = $status_labels[$state['status']] ?? (string) $state['status'];
        $processed = max(
            0,
            (int) ($state['processed'] ?? ((int) $state['scanned'] + (int) $state['skipped_offloaded']))
        );

        $description  = '<p>' . esc_html__('Informational summary of the media provenance pipeline on this site.', 'encypher-provenance') . '</p>';
        $description .= '<ul>';
        if ('complete' === $state['status'] && 0 === (int) $state['total']) {
            $description .= '<li>' . esc_html__('Library scan: No media to scan.', 'encypher-provenance') . '</li>';
        } else {
            $description .= '<li>' . sprintf(
                /* translators: 1: scan status, 2: processed count, 3: total count. */
                esc_html__('Library scan: %1$s (%2$s of %3$s attachments processed).', 'encypher-provenance'),
                esc_html($scan_status),
                esc_html(number_format_i18n($processed)),
                esc_html(number_format_i18n(max($processed, (int) $state['total'])))
            ) . '</li>';
        }
        $description .= '<li>' . sprintf(
            /* translators: %s: number of attachments carrying source C2PA provenance found by the scan. */
            esc_html__('Source provenance found by the scan: %s.', 'encypher-provenance'),
            esc_html(number_format_i18n((int) $state['found']))
        ) . '</li>';
        $description .= '<li>' . sprintf(
            /* translators: %s: number of attachments skipped as offloaded or missing. */
            esc_html__('Files skipped as offloaded or missing locally: %s.', 'encypher-provenance'),
            esc_html(number_format_i18n((int) $state['skipped_offloaded']))
        ) . '</li>';
        $description .= '<li>' . sprintf(
            /* translators: %s: number of attachments whose last sign attempt errored. */
            esc_html__('Attachments needing attention (last sign attempt errored): %s.', 'encypher-provenance'),
            esc_html(number_format_i18n($sign_errors))
        ) . '</li>';
        $description .= '</ul>';

        // Stall escalation: when the background queue is stalled because
        // WP-Cron is not firing, escalate from informational to critical and
        // explain both the cause and the remedy.
        $status  = $sign_errors > 0 ? 'recommended' : 'good';
        $color   = $sign_errors > 0 ? 'orange' : 'gray';
        $label   = $sign_errors > 0
            ? __('Encypher media signing needs attention', 'encypher-provenance')
            : __('Encypher media pipeline status', 'encypher-provenance');
        $actions = '';
        $stall = class_exists(Admin::class) ? Admin::detect_queue_stall() : ['stalled' => false];
        if (! empty($stall['stalled'])) {
            $status = 'critical';
            $color  = 'red';
            $label  = __('Encypher background signing is stalled', 'encypher-provenance');
            $description .= '<p>' . esc_html__('Encypher has queued signing or media-scan work that has not run for several minutes. WordPress cron (WP-Cron) fires only when your site receives a visitor request, so on a quiet site (or when DISABLE_WP_CRON is set without a replacement) queued work can wait indefinitely.', 'encypher-provenance') . '</p>';
            $description .= '<p>' . sprintf(
                /* translators: 1: DISABLE_WP_CRON constant name, 2: wp-cron.php filename */
                esc_html__('Remedy: define %1$s as true in wp-config.php and add a real system cron that runs %2$s on a fixed schedule (for example every five minutes). You can also use "Process queue now" on the Encypher Setup & Health screen to drain the backlog immediately.', 'encypher-provenance'),
                '<code>DISABLE_WP_CRON</code>',
                '<code>wp-cron.php</code>'
            ) . '</p>';
            $actions = sprintf(
                '<p><a href="%s" class="button button-primary">%s</a></p>',
                esc_url(admin_url('admin.php?page=encypher')),
                esc_html__('Open Encypher Setup &amp; Health', 'encypher-provenance')
            );
        }

        return [
            'label'       => $label,
            'status'      => $status,
            'badge'       => [
                'label' => __('Encypher', 'encypher-provenance'),
                'color' => $color,
            ],
            'description' => $description,
            'actions'     => $actions,
            'test'        => 'encypher_media_pipeline',
        ];
    }

    /**
     * Fetch a signed post like a reader would and verify the served text.
     *
     * @return array Site Health result array.
     */
    private function perform_integrity_test(): array
    {
        $result = [
            'label'       => __('Signed content verifies end-to-end', 'encypher-provenance'),
            'status'      => 'good',
            'badge'       => [
                'label' => __('Security', 'encypher-provenance'),
                'color' => 'blue',
            ],
            'description' => '<p>' . esc_html__('Your theme and plugin stack preserves provenance bytes end-to-end. The most recent signed post was fetched exactly as readers receive it, and the Encypher API verified the served article text against its C2PA signature.', 'encypher-provenance') . '</p>',
            'actions'     => '',
            'test'        => 'encypher_provenance_integrity',
        ];

        $post_id = $this->find_latest_signed_post();
        if (! $post_id) {
            $result['status']      = 'recommended';
            $result['label']       = __('No signed post available for a provenance integrity check', 'encypher-provenance');
            $result['description'] = '<p>' . esc_html__('Sign a post first — then this test can fetch it exactly as readers receive it and confirm your theme and plugin stack serves the signed bytes unmodified.', 'encypher-provenance') . '</p>';

            return $result;
        }

        $signed_text = get_post_meta($post_id, '_encypher_signed_text', true);
        if (! is_string($signed_text) || '' === $signed_text) {
            // Should not happen (the query requires the meta), but never
            // report a false PASS/FAIL on an empty snapshot.
            $result['status']      = 'recommended';
            $result['label']       = __('No signed post available for a provenance integrity check', 'encypher-provenance');
            $result['description'] = '<p>' . esc_html__('Sign a post first — then this test can fetch it exactly as readers receive it and confirm your theme and plugin stack serves the signed bytes unmodified.', 'encypher-provenance') . '</p>';

            return $result;
        }

        $permalink = get_permalink($post_id);
        if (! $permalink) {
            $result['status']      = 'recommended';
            $result['label']       = __('Provenance integrity could not be checked', 'encypher-provenance');
            $result['description'] = '<p>' . esc_html__('The most recent signed post has no public permalink, so its rendered output could not be fetched.', 'encypher-provenance') . '</p>';

            return $result;
        }

        $html = $this->fetch_rendered_page($permalink);
        if (is_wp_error($html)) {
            $result['status']      = 'recommended';
            $result['label']       = __('Provenance integrity could not be checked', 'encypher-provenance');
            $result['description'] = '<p>' . sprintf(
                /* translators: 1: permalink URL, 2: transport error message */
                esc_html__('The signed post at %1$s could not be fetched from this server (%2$s). Loopback requests may be blocked — see the "loopback request" test above.', 'encypher-provenance'),
                '<code>' . esc_url($permalink) . '</code>',
                esc_html($html->get_error_message())
            ) . '</p>';

            return $result;
        }

        // Raw HTML equality is diagnostic only: a theme may add harmless
        // structure around block content. Verification operates on canonical
        // text, and the full-page extraction also includes theme chrome.
        $bytes_exact = (false !== strpos($html, $signed_text));

        $parser = new HtmlParser();
        $page_text = $parser->extract_text_for_verify($html);
        $signed_page_text = $parser->extract_text_for_verify($signed_text);
        [$verification_text, $canonical_exact] = self::select_served_verification_payload(
            $page_text,
            $signed_page_text
        );
        $verdict = $this->call_verify_api($verification_text);

        if (is_wp_error($verdict)) {
            $result['status']      = 'recommended';
            $result['label']       = __('Provenance integrity could not be fully checked', 'encypher-provenance');
            $result['description'] = '<p>' . sprintf(
                /* translators: %s: API error message */
                esc_html__('The signed post was fetched, but the Encypher verification API could not be reached to verify the served text (%s). Check the API base URL in Encypher settings and try again.', 'encypher-provenance'),
                esc_html($verdict->get_error_message())
            ) . '</p>';

            return $result;
        }

        if (true !== $verdict) {
            $result['status']      = 'critical';
            $result['label']       = __('Signed text is being modified after Encypher injects it', 'encypher-provenance');
            $result['description'] = '<p>' . esc_html__('A filter or output plugin on this site modifies signed text after Encypher injects it — most often an HTML minifier or "smart quotes" plugin rewriting the page as it is sent to readers. The served article no longer verifies against its C2PA signature, so on-page verification of this site will fail for every reader.', 'encypher-provenance') . '</p>'
                . '<p>' . esc_html__('Try disabling HTML minification / page optimization and typography ("smart quotes", texturizing) plugins for single post pages, then re-run this test. Caching plugins that serve pages captured before signing can also cause this — purge the page cache for signed posts. If the page is served unmodified, the post may simply need to be re-signed (for example after a plugin update that changed the embedding format).', 'encypher-provenance') . '</p>';

            return $result;
        }

        if (! $bytes_exact) {
            $result['description'] .= '<p>' . (
                $canonical_exact
                    ? esc_html__('The theme changed surrounding HTML, but the exact signed article text reached readers and verified.', 'encypher-provenance')
                    : esc_html__('The served bytes differ slightly from the signed snapshot, but the Encypher verifier accepted the supported normalization.', 'encypher-provenance')
            ) . '</p>';
        }

        return $result;
    }

    /**
     * Remove unrelated theme chrome before sending a rendered post to verify.
     *
     * @return array{0:string,1:bool} Verification payload and exact-match flag.
     */
    private static function select_served_verification_payload(
        string $page_text,
        string $signed_page_text
    ): array {
        if ('' === $signed_page_text) {
            return [$page_text, false];
        }

        $offset = strpos($page_text, $signed_page_text);
        if (false === $offset) {
            return [$page_text, false];
        }

        return [substr($page_text, $offset, strlen($signed_page_text)), true];
    }

    /**
     * Most recent published post whose provenance status is signed AND which
     * carries a signed-text snapshot (i.e. the render pipeline injects it).
     *
     * @return int Post ID, or 0 when no signed post exists.
     */
    private function find_latest_signed_post(): int
    {
        $query = new WP_Query([
            'post_type'              => 'any',
            'post_status'            => 'publish',
            'posts_per_page'         => 1,
            'orderby'                => 'date',
            'order'                  => 'DESC',
            'fields'                 => 'ids',
            'no_found_rows'          => true,
            'update_post_term_cache' => false,
            'update_post_meta_cache' => false,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- on-demand admin health check, cached 5 minutes.
            'meta_query'             => [
                'relation' => 'AND',
                [
                    'key'     => '_encypher_provenance_status',
                    'value'   => self::SIGNED_STATUSES,
                    'compare' => 'IN',
                ],
                [
                    'key'     => '_encypher_signed_text',
                    'compare' => 'EXISTS',
                ],
            ],
        ]);

        return $query->posts ? (int) $query->posts[0] : 0;
    }

    /**
     * Fetch a public permalink server-side, bypassing caches.
     *
     * Falls back to a loopback request against 127.0.0.1 with the original
     * Host header when the public host/port is not reachable from inside the
     * server itself (reverse proxies, container port mappings) — the page is
     * still rendered by this exact WordPress with all filters applied.
     *
     * @param string $permalink Public post URL.
     * @return string|WP_Error Response body, or transport error.
     */
    private function fetch_rendered_page(string $permalink)
    {
        $args = [
            'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin Site Health self-check fetching this site's own rendered page; not a front-end request.
            // Do not follow an auth gateway or canonical-host redirect into a
            // different 200 page. A non-200 response triggers the local
            // WordPress loopback below with the original Host header.
            'redirection' => 0,
            'sslverify' => false,
            'headers'   => [
                'Cache-Control' => 'no-cache',
                'Pragma'        => 'no-cache',
            ],
        ];

        $response = wp_remote_get($permalink, $args); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- admin Site Health self-check of this site's own public permalink; admin-triggered, bounded.
        $code = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response);

        if (200 !== $code) {
            $parts = wp_parse_url($permalink);
            $host = isset($parts['host']) ? $parts['host'] : '';
            if ('' !== $host && '127.0.0.1' !== $host) {
                $loopback = 'http://127.0.0.1'
                    . (isset($parts['path']) ? $parts['path'] : '/')
                    . (isset($parts['query']) ? '?' . $parts['query'] : '');
                $args['headers']['Host'] = $host . (isset($parts['port']) ? ':' . $parts['port'] : '');
                $retry = wp_remote_get($loopback, $args); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- admin Site Health loopback retry against 127.0.0.1 for this site's own page; admin-triggered.
                if (! is_wp_error($retry) && 200 === (int) wp_remote_retrieve_response_code($retry)) {
                    $response = $retry;
                    $code = 200;
                }
            }
        }

        if (is_wp_error($response)) {
            return $response;
        }

        if (200 !== $code) {
            return new WP_Error(
                'http_error',
                sprintf(
                    /* translators: %d: HTTP status code */
                    __('HTTP status %d', 'encypher-provenance'),
                    (int) wp_remote_retrieve_response_code($response)
                )
            );
        }

        return (string) wp_remote_retrieve_body($response);
    }

    /**
     * POST article text to the configured /verify endpoint.
     *
     * Same call shape as the badge backend (Rest::handle_verify_request):
     * JSON `{"text": ...}` against `{api_base_url}/verify`, no auth required.
     *
     * @param string $text Article text with VS markers preserved.
     * @return bool|WP_Error True when the API verdict is valid, false when
     *                       the API answered but the signature did not
     *                       verify, WP_Error on transport/configuration
     *                       failure.
     */
    private function call_verify_api(string $text)
    {
        $settings = get_option('encypher_provenance_settings', []);
        $base_url = isset($settings['api_base_url']) ? rtrim((string) $settings['api_base_url'], '/') : '';
        if ('' === $base_url) {
            return new WP_Error('missing_configuration', __('Please configure the Encypher API base URL.', 'encypher-provenance'));
        }

        $response = ApiUrlGuard::guarded_remote_post($base_url . '/verify', [
            'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin Site Health verify probe to the configured API; not a front-end request.
            'headers' => [
                'Content-Type' => 'application/json',
                'Accept'       => 'application/json',
            ],
            'body'    => wp_json_encode(['text' => $text]),
        ]);

        if (is_wp_error($response)) {
            return $response;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $decoded = json_decode(wp_remote_retrieve_body($response), true);

        if ($code >= 400 || ! is_array($decoded)) {
            return new WP_Error(
                'backend_error',
                sprintf(
                    /* translators: %d: HTTP status code */
                    __('Verification API responded with status %d', 'encypher-provenance'),
                    $code
                )
            );
        }

        return ! empty($decoded['success']) && ! empty($decoded['data']['valid']);
    }
}
