<?php
/**
 * External notarization of the history hash chain (1.10.0).
 *
 * A separate, opt-in-only channel from the SEO benchmark network
 * (Seoforge_Intel): a site can notarize its integrity chain without ever
 * sending a single benchmark snapshot, and vice versa. What travels here is
 * NEVER a measurement — only the chain's own already-public-within-the-site
 * shape: a SHA-256 chain head, the date range it covers, and a row count.
 * No data-processing agreement is needed because nothing personally
 * identifying, and no analytics value, ever leaves the site.
 *
 * Transport is deliberately identical to Seoforge_Intel's outbound push
 * (same X-SF-Site / X-SF-Timestamp / X-SF-Signature HMAC scheme, same
 * shared site_id/secret from Seoforge_Intel::get_registration() — see that
 * class's docblock) so this reuses the one signed channel the plugin
 * already has instead of inventing a second one. Reusing the registration
 * means a notarize-only opt-in still needs the site registered with the
 * hub; do_register() is self-triggered here exactly the way
 * Seoforge_Intel::maybe_schedule() self-triggers it for the benchmark path,
 * so "independent opt-in" doesn't silently require the admin to also flip
 * the benchmark switch first.
 *
 * What this DOES and DOES NOT prove, stated plainly (also surfaced in the
 * Verify tab UI, never buried):
 *  - It proves the chain has NOT been altered since the moment a given
 *    checkpoint was accepted by the hub — retroactive tampering with rows
 *    covered by an already-notarized checkpoint becomes detectable.
 *  - History predating a site's FIRST checkpoint is not protected at all —
 *    there is nothing external to compare it against.
 *  - It proves immutability, not truth: a row that was already wrong (or
 *    fabricated) the day it was captured notarizes exactly as cleanly as a
 *    correct one. This only detects LATER tampering.
 *  - The hub is a trusted third party. This is not a zero-trust proof; it
 *    just moves "trust the site operator alone" to "trust the site operator
 *    AND an independent, harder-to-quietly-rewrite external record".
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Notary {

    /** Settings key (stored in the shared seoforge_settings option). Independent of Seoforge_Intel::OPT_ENABLED. */
    const OPT_ENABLED = 'notary_enabled';

    /**
     * Per-blog state (last push result, last comparison). Multisite: a
     * single option holding one entry per blog_id — NOT a plain per-blog
     * get_option()/update_option(), because a network admin can trigger a
     * manual push/compare for another site from the Verify tab's site
     * picker WITHOUT switch_to_blog() (same trust boundary documented on
     * Seoforge_Verify::resolve_target_blog_id() — chain data is selected by
     * blog_id column, no site-context switch needed). A plain per-blog
     * option would then silently read/write the ADMIN'S OWN current site
     * instead of the targeted one.
     */
    const STATE_OPTION = 'seoforge_notary_state';

    const SCHEMA_VERSION = 1;

    /** Shared nonce for the manual push/compare forms on the Verify tab. */
    const NONCE = 'seoforge_notary_run';

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( 'admin_post_seoforge_notary_push_now', __CLASS__, 'handle_push_now' );
        $loader->add_action( 'admin_post_seoforge_notary_compare_now', __CLASS__, 'handle_compare_now' );
    }

    public static function opted_in() {
        return (bool) Seoforge_Settings::get( self::OPT_ENABLED, false );
    }

    /* ------------------------------------------------------------- state */

    private static function state_all() {
        $all = is_multisite()
            ? get_site_option( self::STATE_OPTION, array() )
            : get_option( self::STATE_OPTION, array() );
        return is_array( $all ) ? $all : array();
    }

    private static function state( $blog_id ) {
        $all = self::state_all();
        return isset( $all[ $blog_id ] ) && is_array( $all[ $blog_id ] ) ? $all[ $blog_id ] : array();
    }

    private static function set_state( $blog_id, array $changes ) {
        $all              = self::state_all();
        $all[ $blog_id ]  = array_merge( isset( $all[ $blog_id ] ) ? $all[ $blog_id ] : array(), $changes );
        if ( is_multisite() ) {
            update_site_option( self::STATE_OPTION, $all );
        } else {
            update_option( self::STATE_OPTION, $all, false );
        }
    }

    private static function record_success( $blog_id, array $checkpoint ) {
        self::set_state( $blog_id, array(
            'last_pushed_at'     => gmdate( 'Y-m-d\TH:i:s\Z' ),
            'last_checkpoint_id' => (string) $checkpoint['checkpoint_id'],
            'last_push_error'    => null,
        ) );
    }

    private static function record_error( $blog_id, $code ) {
        self::set_state( $blog_id, array(
            'last_push_error' => array( 'code' => (string) $code, 'at' => gmdate( 'Y-m-d\TH:i:s\Z' ) ),
        ) );
    }

    private static function record_compare( $blog_id, array $result ) {
        self::set_state( $blog_id, array(
            'last_compare' => array_merge( $result, array( 'at' => gmdate( 'Y-m-d\TH:i:s\Z' ) ) ),
        ) );
    }

    /** Admin-card read model: current opt-in/registration state plus the last push and comparison, if any. */
    public static function status( $blog_id = null ) {
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;
        $s       = self::state( $blog_id );
        return array(
            'enabled'            => self::opted_in(),
            'registered'         => Seoforge_Intel::is_registered(),
            'last_pushed_at'     => isset( $s['last_pushed_at'] ) ? (string) $s['last_pushed_at'] : '',
            'last_checkpoint_id' => isset( $s['last_checkpoint_id'] ) ? (string) $s['last_checkpoint_id'] : '',
            'last_push_error'    => isset( $s['last_push_error'] ) && is_array( $s['last_push_error'] ) ? $s['last_push_error'] : null,
            'last_compare'       => isset( $s['last_compare'] ) && is_array( $s['last_compare'] ) ? $s['last_compare'] : null,
        );
    }

    /* --------------------------------------------------------- checkpoint */

    /**
     * Deterministic checkpoint id: same chain_head + same covered period ->
     * same id, so a retried push (network timeout, cron re-run) dedups on
     * the hub instead of creating a second row for the same state. UUID-
     * shaped hash, same pattern as Seoforge_Intel::snapshot_id().
     */
    private static function checkpoint_id( $chain_head, $covered_from, $covered_to ) {
        $h = md5( (string) $chain_head . '|' . (string) $covered_from . '|' . (string) $covered_to );
        return sprintf(
            '%s-%s-%s-%s-%s',
            substr( $h, 0, 8 ), substr( $h, 8, 4 ), substr( $h, 12, 4 ), substr( $h, 16, 4 ), substr( $h, 20, 12 )
        );
    }

    /** Y-m-d date string -> ISO8601 instant at midnight UTC, the canonical representation of a date-only value. */
    private static function date_to_iso( $ymd ) {
        return (string) $ymd . 'T00:00:00Z';
    }

    /**
     * Local blog_id -> the hub's wire convention. Internally (state keys,
     * Seoforge_History_Chain queries) a single-site install's blog_id is 0,
     * matching how its history rows are stored. The hub's NotaryCheckpoint
     * schema requires blog_id >= 1 for every install, single-site included
     * (see shieldforge-intel schemas/notary_checkpoint.py), so this
     * boundary-only translation keeps the internal 0 convention intact while
     * still satisfying the hub's contract on the wire.
     */
    private static function hub_blog_id( $blog_id ) {
        $blog_id = (int) $blog_id;
        return $blog_id > 0 ? $blog_id : 1;
    }

    /**
     * Pure: a Seoforge_History_Chain::verify() result -> the wire-shaped
     * checkpoint body, or null when there is nothing chained yet for this
     * blog (checked === 0) — notarizing an empty chain would just anchor
     * the hub's record to genesis, which carries no information.
     *
     * @return array|null
     */
    public static function build_checkpoint( array $chain, $site_id, $blog_id, $now = null ) {
        if ( empty( $chain['checked'] ) ) {
            return null;
        }
        $now = null === $now ? time() : (int) $now;
        return array(
            'schema_version' => self::SCHEMA_VERSION,
            'site_id'        => (string) $site_id,
            'blog_id'        => self::hub_blog_id( $blog_id ),
            'checkpoint_id'  => self::checkpoint_id( $chain['chain_head'], $chain['covered_from'], $chain['covered_to'] ),
            'chain_head'     => (string) $chain['chain_head'],
            'covered_from'   => self::date_to_iso( $chain['covered_from'] ),
            'covered_to'     => self::date_to_iso( $chain['covered_to'] ),
            'row_count'      => (int) $chain['checked'],
            'captured_at'    => gmdate( 'Y-m-d\TH:i:s\Z', $now ),
        );
    }

    /* --------------------------------------------------------------- push */

    /**
     * Build + push the current chain-head checkpoint for one blog. Returns
     * array{ok:bool,message:string}. Never throws — every failure path
     * (missing opt-in, missing registration, transport error, a rejecting
     * hub response) is a plain returned/logged result, so this is safe to
     * call unconditionally from the daily cron for every blog.
     *
     * @param array|null $chain Pre-computed Seoforge_History_Chain::verify()
     *                          result, when the caller already has one (the
     *                          daily cron does — see Seoforge_Verify::run_daily_check()),
     *                          to avoid re-querying the same chain twice.
     */
    public static function push( $blog_id = null, array $chain = null ) {
        if ( ! self::opted_in() ) {
            return array( 'ok' => false, 'message' => 'not_opted_in' );
        }
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;

        if ( ! Seoforge_Intel::is_registered() ) {
            $r = Seoforge_Intel::do_register();
            if ( ! $r['ok'] ) {
                self::record_error( $blog_id, 'register_failed:' . $r['message'] );
                return array( 'ok' => false, 'message' => 'not_registered' );
            }
        }
        $reg = Seoforge_Intel::get_registration();
        if ( empty( $reg['site_id'] ) || empty( $reg['secret'] ) ) {
            return array( 'ok' => false, 'message' => 'not_registered' );
        }

        $chain      = null === $chain ? Seoforge_History_Chain::verify( $blog_id ) : $chain;
        $checkpoint = self::build_checkpoint( $chain, $reg['site_id'], $blog_id );
        if ( null === $checkpoint ) {
            return array( 'ok' => false, 'message' => 'no_chain_yet' );
        }

        return self::send_checkpoint( $blog_id, $checkpoint, $reg );
    }

    private static function send_checkpoint( $blog_id, array $checkpoint, array $reg ) {
        $body = wp_json_encode( $checkpoint );
        $ts   = (string) time();
        $sig  = hash_hmac( 'sha256', $ts . "\n" . $body, (string) $reg['secret'] );
        $url  = Seoforge_Intel::api_base() . '/v1/seo/notary/checkpoints';

        if ( class_exists( 'Seoforge_Utils' ) && ! Seoforge_Utils::is_safe_remote_url( $url ) ) {
            self::record_error( $blog_id, 'unsafe_url' );
            return array( 'ok' => false, 'message' => 'unsafe_url' );
        }

        $args = array(
            'timeout' => 20,
            'headers' => array(
                'Content-Type'   => 'application/json',
                'X-SF-Site'      => (string) $reg['site_id'],
                'X-SF-Timestamp' => $ts,
                'X-SF-Signature' => $sig,
            ),
            'body'    => $body,
        );
        $resp = class_exists( 'Seoforge_Utils' )
            ? Seoforge_Utils::safe_remote_request( 'post', $url, $args )
            : wp_remote_post( $url, $args );

        if ( is_wp_error( $resp ) ) {
            self::record_error( $blog_id, 'transport_error' );
            return array( 'ok' => false, 'message' => 'transport_error' );
        }

        $code = (int) wp_remote_retrieve_response_code( $resp );
        if ( 200 === $code ) {
            self::record_success( $blog_id, $checkpoint );
            return array( 'ok' => true, 'message' => 'accepted', 'checkpoint' => $checkpoint );
        }

        $data = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
        $err  = is_array( $data ) && isset( $data['error'] ) ? (string) $data['error'] : 'http_' . $code;

        // A rejected identity (401/403) means the shared credentials this channel
        // borrows from Seoforge_Intel are stale — clear them so the NEXT push
        // (cron or manual) re-registers, exactly like Seoforge_Intel::push() does
        // for its own 401/403 case. A 409 (rollback_rejected) or 422
        // (schema_invalid) is a data problem, not a credential problem, so the
        // registration is left alone.
        if ( 401 === $code || 403 === $code ) {
            Seoforge_Intel::clear_registration();
        }

        self::record_error( $blog_id, $err );
        return array( 'ok' => false, 'message' => $err );
    }

    /** Cron entry point: swallow-everything wrapper around push(), for Seoforge_Verify::run_daily_check(). */
    public static function cron_push( $blog_id, array $chain ) {
        self::push( $blog_id, $chain );
    }

    /* ------------------------------------------------------------ compare */

    /**
     * GET the latest checkpoint the hub holds for this blog. Returns
     * array{ok:bool,checkpoint:?array,message:?string} — checkpoint is null
     * (with ok:true) when the hub has never accepted one for this blog yet.
     */
    public static function fetch_latest_checkpoint( $blog_id = null ) {
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;
        $reg     = Seoforge_Intel::get_registration();
        if ( empty( $reg['site_id'] ) || empty( $reg['secret'] ) ) {
            return array( 'ok' => false, 'checkpoint' => null, 'message' => 'not_registered' );
        }

        $url = Seoforge_Intel::api_base() . '/v1/seo/notary/checkpoints/latest?blog_id=' . self::hub_blog_id( $blog_id );
        if ( class_exists( 'Seoforge_Utils' ) && ! Seoforge_Utils::is_safe_remote_url( $url ) ) {
            return array( 'ok' => false, 'checkpoint' => null, 'message' => 'unsafe_url' );
        }

        $ts   = (string) time();
        $sig  = hash_hmac( 'sha256', $ts . "\n", (string) $reg['secret'] );
        $args = array(
            'timeout' => 15,
            'headers' => array(
                'X-SF-Site'      => (string) $reg['site_id'],
                'X-SF-Timestamp' => $ts,
                'X-SF-Signature' => $sig,
            ),
        );
        $resp = class_exists( 'Seoforge_Utils' )
            ? Seoforge_Utils::safe_remote_request( 'get', $url, $args )
            : wp_remote_get( $url, $args );

        if ( is_wp_error( $resp ) ) {
            return array( 'ok' => false, 'checkpoint' => null, 'message' => 'transport_error' );
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );
        if ( 404 === $code ) {
            return array( 'ok' => true, 'checkpoint' => null, 'message' => 'no_checkpoint' );
        }
        if ( 200 !== $code ) {
            return array( 'ok' => false, 'checkpoint' => null, 'message' => 'http_' . $code );
        }
        $data = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
        if ( ! is_array( $data ) || empty( $data['checkpoint'] ) || ! is_array( $data['checkpoint'] ) ) {
            return array( 'ok' => false, 'checkpoint' => null, 'message' => 'non_json' );
        }
        return array( 'ok' => true, 'checkpoint' => $data['checkpoint'] );
    }

    /**
     * Pure: a local Seoforge_History_Chain::verify() result (recomputed up
     * to the remote checkpoint's covered_to date) vs. the remote checkpoint
     * -> a verdict. Split out from compare() so the comparison logic itself
     * is unit-testable without any HTTP.
     *
     * 'since' on a mismatch is the earliest date the discrepancy can
     * honestly be pinned to: the local chain's OWN break date when the
     * local chain itself is internally broken (an exact tamper location),
     * or the covered_from of the local recompute otherwise — the local
     * chain is internally consistent but its head still disagrees with the
     * hub's record, which can only mean something changed somewhere at or
     * before that boundary; earlier than that boundary is simply not part
     * of what was just recomputed.
     *
     * @return array{status:string,since?:?string}
     */
    public static function build_compare( array $local_chain, $remote_checkpoint ) {
        if ( null === $remote_checkpoint ) {
            return array( 'status' => 'no_checkpoint' );
        }
        $remote_head = isset( $remote_checkpoint['chain_head'] ) ? (string) $remote_checkpoint['chain_head'] : '';
        $local_head  = isset( $local_chain['chain_head'] ) ? (string) $local_chain['chain_head'] : '';

        if ( '' !== $local_head && $local_head === $remote_head ) {
            return array( 'status' => 'match' );
        }

        $since = ( ! $local_chain['ok'] && ! empty( $local_chain['break'] ) )
            ? $local_chain['break']['date']
            : ( isset( $local_chain['covered_from'] ) ? $local_chain['covered_from'] : null );

        return array( 'status' => 'mismatch', 'since' => $since );
    }

    /**
     * Fetch the latest hub checkpoint and compare it against a freshly
     * recomputed local chain for the same covered period. Persists the
     * result into this blog's status(). Returns the same shape as
     * build_compare(), plus 'status' => 'error' on a fetch failure.
     */
    public static function compare( $blog_id = null ) {
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;

        $fetched = self::fetch_latest_checkpoint( $blog_id );
        if ( ! $fetched['ok'] ) {
            return array( 'status' => 'error', 'message' => $fetched['message'] );
        }
        if ( empty( $fetched['checkpoint'] ) ) {
            $result = array( 'status' => 'no_checkpoint' );
            self::record_compare( $blog_id, $result );
            return $result;
        }

        $remote = $fetched['checkpoint'];
        $to     = isset( $remote['covered_to'] ) ? substr( (string) $remote['covered_to'], 0, 10 ) : null;
        $local  = Seoforge_History_Chain::verify( $blog_id, null, $to );

        $result = self::build_compare( $local, $remote );
        self::record_compare( $blog_id, $result );
        return $result;
    }

    /* ------------------------------------------------------ manual-run UI */

    private static function guard_manual_run() {
        check_admin_referer( self::NONCE );
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to do this.', 'brainwerk-seo-suite' ), 403 );
        }
    }

    /**
     * Same trust rule as Seoforge_Verify::resolve_target_blog_id(): a network
     * admin may target another site's chain from the Verify tab's site
     * picker; anyone else (including a subsite Administrator) is confined to
     * their own current blog no matter what was posted.
     */
    private static function resolve_target_blog_id() {
        $current = Seoforge_Utils::current_blog_id();
        if ( ! is_multisite() || ! Seoforge_Utils::can_view_network_scope() ) {
            return $current;
        }
        $requested = isset( $_POST['target_blog_id'] ) ? (int) $_POST['target_blog_id'] : $current; // phpcs:ignore WordPress.Security.NonceVerification.Missing
        if ( $requested === $current ) {
            return $current;
        }
        return get_site( $requested ) ? $requested : $current;
    }

    private static function redirect_back( $ran ) {
        wp_safe_redirect( add_query_arg( array( 'seoforge_verify_ran' => $ran ), Seoforge_Admin::tab_url( 'verify' ) ) );
        exit;
    }

    public static function handle_push_now() {
        self::guard_manual_run();
        $blog_id = self::resolve_target_blog_id();
        $result  = self::push( $blog_id );
        self::redirect_back( $result['ok'] ? 'notary_pushed' : 'notary_push_failed' );
    }

    public static function handle_compare_now() {
        self::guard_manual_run();
        $blog_id = self::resolve_target_blog_id();
        self::compare( $blog_id );
        self::redirect_back( 'notary_compared' );
    }
}
