<?php
/**
 * Intel Client — inbound direction (Ed25519-verified pull feed).
 *
 * Seoforge_Intel (class-seoforge-intel.php) only PUSHES aggregate snapshots
 * to the hub. This class is the return channel: it self-pulls the signed
 * IntelDelta feed (peer benchmarks, bot-reputation notes) from
 * `GET {api_base}/v1/seo/intel`, verifies it against a pinned Ed25519 key
 * (mirrors Shieldforge_Intel_Client's trust model in the sibling security
 * plugin), and exposes it to the admin UI / later consumers.
 *
 * Trust anchor: nothing from the feed is ever trusted unless
 * sodium_crypto_sign_verify_detached() passes over the exact `signed_payload`
 * bytes — a tampered, unsigned, rolled-back, stale or malformed envelope is
 * simply discarded. This is an INBOUND-ONLY channel: the only thing this
 * class ever sends is the site id, a timestamp, an HMAC signature and the
 * `since` cursor — never anything about visitors.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Intel_Client {

    /** Verified envelope { signed_payload, signature, feed_version, fetched_at }. */
    const FEED_OPTION = 'seoforge_intel_feed';

    /** Highest feed_version ever accepted — rollback/replay guard. */
    const SEEN_VERSION_OPTION = 'seoforge_intel_feed_seen_version';

    /** Terse last-rejection reason for the admin card: array{code,at}. */
    const LAST_ERROR_OPTION = 'seoforge_intel_feed_last_error';

    /** Consecutive-transport-failure counter + next allowed retry (backoff). */
    const BACKOFF_OPTION = 'seoforge_intel_feed_backoff';

    const CRON_HOOK = 'seoforge_intel_feed_pull';

    const SCHEMA_VERSION = 1;

    /**
     * Pinned Ed25519 public keys (base64, 32 bytes each) of the central feed
     * signer. Same hub, same signing key as Shieldforge_Intel_Client::PUBKEYS_B64
     * in the sibling security plugin — double-pinned so a future key rotation
     * on the hub is seamless: a signature is accepted if it verifies against
     * ANY pinned key.
     *
     * Index 0: the CURRENT signing key.
     * Index 1: the pre-pinned ROTATION key.
     *
     * SECURITY BOUNDARY: these hardcoded pins — not the `seoforge_intel_pubkeys`
     * filter below — are what makes this channel trustworthy. The filter exists
     * solely so PHPUnit fixtures can sign with a throwaway keypair; production
     * code must never use it to add or replace a production key.
     */
    const PUBKEYS_B64 = array(
        'buo7WMgugPKhSFckLe1WAdnD9FZOIhj/Nd20+sKxlZA=',
        'QVAPvyZukd1QqUM2gJe8ze6oN9Iab7HnR1HZujEfuUo=',
    );

    /** Per-request memo: null = not loaded, false = none/invalid, array = payload. */
    private static $cache = null;

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( self::CRON_HOOK, __CLASS__, 'cron_pull' );
        $loader->add_action( 'init', __CLASS__, 'maybe_schedule' );
        $loader->add_action( 'admin_post_seoforge_intel_feed_pull_now', __CLASS__, 'handle_pull_now' );
    }

    /* ------------------------------------------------------------------
     * Verification core
     * ------------------------------------------------------------------ */

    /**
     * The pinned key set. TEST-ONLY override via `seoforge_intel_pubkeys` —
     * lets PHPUnit fixtures sign with a throwaway keypair and gives the hub a
     * rotation escape hatch; it must never be used in production to weaken
     * the hardcoded pins in PUBKEYS_B64.
     */
    private static function pubkeys() {
        $keys = apply_filters( 'seoforge_intel_pubkeys', self::PUBKEYS_B64 );
        return is_array( $keys ) && $keys ? $keys : self::PUBKEYS_B64;
    }

    /** Ed25519 verify over the verbatim payload bytes. Fails closed if ext-sodium
     *  is missing, and accepts a signature that validates against ANY pinned key. */
    public static function verify( $payload_str, $sig_b64 ) {
        if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) {
            return false;
        }
        $sig = base64_decode( (string) $sig_b64, true );
        if ( false === $sig || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $sig ) ) {
            return false;
        }
        foreach ( self::pubkeys() as $pubkey_b64 ) {
            $pub = base64_decode( (string) $pubkey_b64, true );
            if ( false === $pub || SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES !== strlen( $pub ) ) {
                continue;
            }
            if ( sodium_crypto_sign_verify_detached( $sig, (string) $payload_str, $pub ) ) {
                return true;
            }
        }
        return false;
    }

    /* ------------------------------------------------------------------
     * Feed read (verify-on-read, per-request cache)
     * ------------------------------------------------------------------ */

    public static function feed() {
        if ( null !== self::$cache ) {
            return self::$cache ? self::$cache : null;
        }
        self::$cache = false;

        $raw = is_multisite()
            ? get_site_option( self::FEED_OPTION, '' )
            : get_option( self::FEED_OPTION, '' );
        if ( empty( $raw ) ) {
            return null;
        }
        $env = is_array( $raw ) ? $raw : json_decode( (string) $raw, true );
        if ( ! is_array( $env ) || empty( $env['signed_payload'] ) || empty( $env['signature'] ) ) {
            return null;
        }
        if ( ! self::verify( (string) $env['signed_payload'], (string) $env['signature'] ) ) {
            return null;
        }
        $payload = json_decode( (string) $env['signed_payload'], true );
        if ( ! is_array( $payload ) ) {
            return null;
        }
        self::$cache = $payload;
        return $payload;
    }

    /** Thin typed getter. Absent in the wire contract when the k-anonymity cell is too small. */
    public static function benchmarks() {
        $p = self::feed();
        return ( $p && isset( $p['benchmarks'] ) && is_array( $p['benchmarks'] ) ) ? $p['benchmarks'] : null;
    }

    /** Thin typed getter. Absent when the hub has nothing new to say. */
    public static function bot_reputation() {
        $p = self::feed();
        return ( $p && isset( $p['bot_reputation'] ) && is_array( $p['bot_reputation'] ) ) ? $p['bot_reputation'] : null;
    }

    /**
     * Thin typed getter for the network participation signal, e.g.
     * `{ "sites": 3, "unlock_at": 20 }`. Present even when `benchmarks` is
     * omitted — it's how the UI explains *why* there's nothing to compare
     * against yet instead of just going quiet. Absent on older hubs.
     */
    public static function network() {
        $p = self::feed();
        return ( $p && isset( $p['network'] ) && is_array( $p['network'] ) ) ? $p['network'] : null;
    }

    /* ------------------------------------------------------------------
     * Acceptance: schema guard, freshness, rollback protection
     * ------------------------------------------------------------------ */

    private static function seen_version() {
        $v = is_multisite()
            ? get_site_option( self::SEEN_VERSION_OPTION, 0 )
            : get_option( self::SEEN_VERSION_OPTION, 0 );
        return (int) $v;
    }

    private static function set_seen_version( $v ) {
        if ( is_multisite() ) {
            update_site_option( self::SEEN_VERSION_OPTION, (int) $v );
        } else {
            update_option( self::SEEN_VERSION_OPTION, (int) $v, false );
        }
    }

    private static function record_error( $code ) {
        $err = array( 'code' => (string) $code, 'at' => gmdate( 'Y-m-d\TH:i:s\Z' ) );
        if ( is_multisite() ) {
            update_site_option( self::LAST_ERROR_OPTION, $err );
        } else {
            update_option( self::LAST_ERROR_OPTION, $err, false );
        }
    }

    private static function clear_error() {
        if ( is_multisite() ) {
            delete_site_option( self::LAST_ERROR_OPTION );
        } else {
            delete_option( self::LAST_ERROR_OPTION );
        }
    }

    public static function last_error() {
        $e = is_multisite()
            ? get_site_option( self::LAST_ERROR_OPTION, array() )
            : get_option( self::LAST_ERROR_OPTION, array() );
        return is_array( $e ) ? $e : array();
    }

    /**
     * The crypto-independent acceptance gates (schema, freshness, rollback),
     * evaluated on an already-decoded payload. Pulled out of accept_envelope()
     * as its own pure(-ish) seam so PHPUnit can exercise these rules directly
     * even in a test runner without ext-sodium — signature verification is
     * covered separately by verify(). Returns true, or a terse error code.
     */
    public static function validate_gates( array $payload, $now = null ) {
        $now = null === $now ? time() : (int) $now;

        if ( ! isset( $payload['schema_version'] ) || (int) $payload['schema_version'] > self::SCHEMA_VERSION ) {
            return 'schema_invalid';
        }
        if ( empty( $payload['generated_at'] ) ) {
            return 'missing_generated_at';
        }
        $gen_ts = strtotime( (string) $payload['generated_at'] );
        if ( false === $gen_ts ) {
            return 'bad_generated_at';
        }
        if ( $gen_ts < ( $now - 30 * DAY_IN_SECONDS ) ) {
            return 'stale';
        }
        if ( $gen_ts > ( $now + HOUR_IN_SECONDS * 24 ) ) {
            return 'future_dated';
        }
        $feed_version = isset( $payload['feed_version'] ) ? (int) $payload['feed_version'] : -1;
        if ( $feed_version < self::seen_version() ) {
            return 'rollback';
        }
        return true;
    }

    /**
     * Verify + validate a raw envelope from the hub (or a PHPUnit fixture) and,
     * if it passes every gate, persist it as the new feed. Returns bool.
     *
     * Gates, in order: well-formed envelope -> signature -> decodable JSON ->
     * schema_version present and <= 1 -> generated_at within
     * [-30d, +24h] of now -> feed_version >= the highest ever accepted.
     * Any failure stores nothing and records a terse reason for the admin card.
     */
    public static function accept_envelope( $env, $now = null ) {
        $now = null === $now ? time() : (int) $now;

        if ( ! is_array( $env ) || empty( $env['signed_payload'] ) || empty( $env['signature'] ) ) {
            self::record_error( 'malformed_envelope' );
            return false;
        }
        $signed_payload = (string) $env['signed_payload'];
        if ( ! self::verify( $signed_payload, (string) $env['signature'] ) ) {
            self::record_error( 'bad_signature' );
            return false;
        }
        $payload = json_decode( $signed_payload, true );
        if ( ! is_array( $payload ) ) {
            self::record_error( 'decode_failed' );
            return false;
        }
        $gate = self::validate_gates( $payload, $now );
        if ( true !== $gate ) {
            self::record_error( $gate );
            return false;
        }
        $feed_version = isset( $payload['feed_version'] ) ? (int) $payload['feed_version'] : -1;

        $envelope = wp_json_encode( array(
            'signed_payload' => $signed_payload,
            'signature'      => (string) $env['signature'],
            'feed_version'   => $feed_version,
            'fetched_at'     => gmdate( 'Y-m-d\TH:i:s\Z', $now ),
        ) );
        if ( is_multisite() ) {
            update_site_option( self::FEED_OPTION, $envelope );
        } else {
            update_option( self::FEED_OPTION, $envelope, false );
        }
        self::set_seen_version( $feed_version );
        self::clear_error();
        self::$cache = null;
        return true;
    }

    /* ------------------------------------------------------------------
     * Backoff (protects against a not-yet-live / permanently-404 endpoint)
     * ------------------------------------------------------------------ */

    private static function backoff() {
        $b = is_multisite()
            ? get_site_option( self::BACKOFF_OPTION, array() )
            : get_option( self::BACKOFF_OPTION, array() );
        return is_array( $b ) ? $b : array();
    }

    private static function set_backoff( array $b ) {
        if ( is_multisite() ) {
            update_site_option( self::BACKOFF_OPTION, $b );
        } else {
            update_option( self::BACKOFF_OPTION, $b, false );
        }
    }

    /** 1h -> 6h -> 24h (cap), doubling^ escalation keyed off the failure count. */
    private static function note_failure() {
        $b        = self::backoff();
        $failures = isset( $b['failures'] ) ? (int) $b['failures'] + 1 : 1;
        $steps    = array( HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, 24 * HOUR_IN_SECONDS );
        $delay    = $steps[ min( $failures, count( $steps ) ) - 1 ];
        self::set_backoff( array(
            'failures'      => $failures,
            'next_retry_at' => time() + $delay,
        ) );
    }

    private static function reset_backoff() {
        self::set_backoff( array( 'failures' => 0, 'next_retry_at' => 0 ) );
    }

    private static function in_backoff() {
        $b = self::backoff();
        return ! empty( $b['next_retry_at'] ) && time() < (int) $b['next_retry_at'];
    }

    /* ------------------------------------------------------------------
     * Self-pull transport
     * ------------------------------------------------------------------ */

    public static function fetch() {
        if ( ! class_exists( 'Seoforge_Intel' ) || ! Seoforge_Intel::opted_in() || ! Seoforge_Intel::is_registered() ) {
            return;
        }
        if ( self::in_backoff() ) {
            return;
        }
        $reg = Seoforge_Intel::get_registration();
        if ( empty( $reg['site_id'] ) || empty( $reg['secret'] ) ) {
            return;
        }

        $url = Seoforge_Intel::api_base() . '/v1/seo/intel?since=' . self::seen_version();
        if ( class_exists( 'Seoforge_Utils' ) && ! Seoforge_Utils::is_safe_remote_url( $url ) ) {
            self::note_failure();
            self::record_error( 'unsafe_url' );
            return;
        }

        $ts  = (string) time();
        $sig = hash_hmac( 'sha256', $ts . "\n", (string) $reg['secret'] );

        $req_args = array(
            'timeout'    => 10,
            'blocking'   => true,
            'user-agent' => 'SEOForge-Intel/' . ( defined( 'SEOFORGE_VERSION' ) ? SEOFORGE_VERSION : 'dev' ) . '; ' . home_url( '/' ),
            'headers'    => array(
                'X-SF-Site'      => (string) $reg['site_id'],
                'X-SF-Timestamp' => $ts,
                'X-SF-Signature' => $sig,
            ),
        );
        // DNS already validated above; safe_remote_request() re-checks (cheap)
        // and pins the cURL connection to the vetted IP so a rebinding DNS
        // answer between that check and the actual request can't matter.
        $resp = class_exists( 'Seoforge_Utils' )
            ? Seoforge_Utils::safe_remote_request( 'get', $url, $req_args )
            : wp_remote_get( $url, $req_args );

        if ( is_wp_error( $resp ) ) {
            self::note_failure();
            self::record_error( 'transport_error' );
            return;
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );
        if ( 304 === $code ) {
            self::reset_backoff();
            return;
        }
        // The hub route may not exist yet during the parallel rollout — degrade
        // silently and back off rather than hammering a 404/501 daily forever.
        if ( in_array( $code, array( 404, 501 ), true ) ) {
            self::note_failure();
            self::record_error( 'endpoint_' . $code );
            return;
        }
        if ( 200 !== $code ) {
            self::note_failure();
            self::record_error( 'http_' . $code );
            return;
        }
        $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
        if ( ! is_array( $body ) ) {
            self::note_failure();
            self::record_error( 'non_json' );
            return;
        }
        if ( self::accept_envelope( $body ) ) {
            self::reset_backoff();
        }
        // A rejected-but-well-formed response (bad sig/rollback/stale/schema) is a
        // data problem, not a connectivity one — don't punish the transport for it.
    }

    public static function cron_pull() {
        self::fetch();
    }

    /* ------------------------------------------------------------------
     * Scheduling + opt-out purge
     * ------------------------------------------------------------------ */

    public static function maybe_schedule() {
        $active = class_exists( 'Seoforge_Intel' ) && Seoforge_Intel::opted_in() && Seoforge_Intel::is_registered();
        $next   = wp_next_scheduled( self::CRON_HOOK );
        if ( $active && ! $next ) {
            // Just after the existing 03:10 UTC push, so a fresh registration's
            // first pull has something new to fetch.
            wp_schedule_event( strtotime( 'tomorrow 03:40 UTC' ), 'daily', self::CRON_HOOK );
        } elseif ( ! $active && $next ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
        }
    }

    /** Delete every trace of the feed — called on opt-out / disconnect. */
    public static function purge() {
        if ( is_multisite() ) {
            delete_site_option( self::FEED_OPTION );
            delete_site_option( self::SEEN_VERSION_OPTION );
            delete_site_option( self::LAST_ERROR_OPTION );
            delete_site_option( self::BACKOFF_OPTION );
        } else {
            delete_option( self::FEED_OPTION );
            delete_option( self::SEEN_VERSION_OPTION );
            delete_option( self::LAST_ERROR_OPTION );
            delete_option( self::BACKOFF_OPTION );
        }
        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( $next ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
        }
        self::$cache = null;
    }

    /* ------------------------------------------------------------------
     * Read API (admin card)
     * ------------------------------------------------------------------ */

    public static function status() {
        $p    = self::feed();
        $back = self::backoff();
        return array(
            'feed_version'   => $p && isset( $p['feed_version'] ) ? (int) $p['feed_version'] : 0,
            'generated_at'   => $p && isset( $p['generated_at'] ) ? (string) $p['generated_at'] : '',
            'fetched_at'     => self::fetched_at(),
            'verified'       => (bool) $p,
            'last_error'     => self::last_error(),
            'next_retry_at'  => ! empty( $back['next_retry_at'] ) ? (int) $back['next_retry_at'] : 0,
        );
    }

    private static function fetched_at() {
        $raw = is_multisite()
            ? get_site_option( self::FEED_OPTION, '' )
            : get_option( self::FEED_OPTION, '' );
        $env = is_array( $raw ) ? $raw : json_decode( (string) $raw, true );
        return ( is_array( $env ) && isset( $env['fetched_at'] ) ) ? (string) $env['fetched_at'] : '';
    }

    /* -------------------------------------------------------------- handler */

    public static function handle_pull_now() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( '403', 403 );
        }
        check_admin_referer( 'seoforge_intel_feed_pull_now' );
        self::fetch();
        $ref = wp_get_referer();
        wp_safe_redirect( add_query_arg( 'seoforge_intel_feed', 'pulled', $ref ? $ref : admin_url( 'admin.php?page=brainwerk-seo-suite&tab=tools' ) ) );
        exit;
    }
}
