<?php
/**
 * Intel Client — Pfad B (Threat-Intelligence Network), return direction.
 *
 * Consumes the central, Ed25519-signed IntelDelta feed and applies it to the
 * local layer-1 hot path. This is the *payoff* of the network: each install
 * benefits from what every other sensor has seen.
 *
 * The feed lives in a single network option (shieldforge_intel_feed) as a JSON
 * envelope { signed_payload, signature, feed_version, fetched_at }. How it gets
 * there depends on the install:
 *   - sites that CAN reach the API pull GET /v1/intel/current themselves;
 *   - egress-locked sites (e.g. the school multisite) get it pushed by the
 *     central relay over Claude-Talk (store_feed() writes the same option).
 *
 * Trust anchor: the Ed25519 PUBLIC key is PINNED below. Nothing from the feed
 * is applied unless sodium_crypto_sign_verify_detached() passes over the exact
 * signed_payload bytes — so a tampered or unsigned option is simply ignored.
 *
 * Safety: virtual-patch WAF rules apply by default (curated, low false-positive,
 * and only we can sign them). Indicator-based IP blocking is OPT-IN and gated by
 * a high score threshold, so a young feed can never silently lock out visitors.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Intel_Client {

    const FEED_OPTION = 'shieldforge_intel_feed';

    /** Local telemetry for immun_rules stage-maturity (shadow/canary hits, FP suspects). */
    const IMMUN_COUNTERS_OPTION = 'shieldforge_immun_counters';
    const IMMUN_COUNTERS_MAX_KEYS = 1000;
    const IMMUN_REPORT_BATCH = 500;

    /**
     * Pinned Ed25519 public keys (base64, 32 bytes each) of the central feed
     * signer. Double-pinned so a future key rotation is seamless in the
     * field: the hub can flip its active signing key (SF_INTEL_ACTIVE_KEY)
     * without a forced plugin update, since both keys are already trusted
     * here. A signature is accepted if it verifies against ANY pinned key.
     *
     * Index 0: the CURRENT signing key (matches SF_INTEL_SIGNING_KEY /
     *          GET /v1/intel/pubkey on shieldforge-intel.brainwerk.at).
     * Index 1: the pre-pinned ROTATION key (config/signing-next.key on the
     *          hub; not used to sign yet — see GET /v1/intel/pubkeys "next").
     */
    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( $loader ) {
        // Apply virtual patches just after the local WAF (init prio 1).
        $loader->add_action( 'init', __CLASS__, 'inspect', 2 );
    }

    /* ------------------------------------------------------------------
     * Feed load + signature verification
     * ------------------------------------------------------------------ */

    /** Read, verify and decode the signed feed once per request. */
    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;
    }

    /** Ed25519 verify over the verbatim signed_payload bytes (PHP libsodium).
     *  Accepts a signature that validates against ANY pinned key (double-pin
     *  rotation support); fails closed only if none match. */
    private static function verify( $payload_str, $sig_b64 ) {
        if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) {
            return false; // libsodium ships with PHP >= 7.2; fail closed if absent.
        }
        $sig = base64_decode( $sig_b64, true );
        if ( false === $sig || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $sig ) ) {
            return false;
        }
        foreach ( self::PUBKEYS_B64 as $pubkey_b64 ) {
            $pub = base64_decode( $pubkey_b64, true );
            if ( false === $pub || SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES !== strlen( $pub ) ) {
                continue;
            }
            if ( sodium_crypto_sign_verify_detached( $sig, $payload_str, $pub ) ) {
                return true;
            }
        }
        return false;
    }

    /** Persist a freshly-fetched signed feed (used by a future self-pull path;
     *  the relay writes the same option directly via Claude-Talk). */
    public static function store_feed( $signed_payload, $signature, $feed_version ) {
        $env = wp_json_encode( array(
            'signed_payload' => (string) $signed_payload,
            'signature'      => (string) $signature,
            'feed_version'   => (int) $feed_version,
            'fetched_at'     => gmdate( 'Y-m-d\TH:i:s\Z' ),
        ) );
        if ( is_multisite() ) {
            update_site_option( self::FEED_OPTION, $env );
        } else {
            update_option( self::FEED_OPTION, $env, false );
        }
        self::$cache = null;
    }

    /* ------------------------------------------------------------------
     * Application to the hot path
     * ------------------------------------------------------------------ */

    public static function inspect() {
        // Same admin courtesy as the WAF — never lock out a logged-in admin.
        if ( Shieldforge_Settings::get( 'waf_whitelist_admins', true )
            && is_user_logged_in() && current_user_can( 'manage_options' ) ) {
            return;
        }
        $payload = self::feed();
        if ( ! $payload ) {
            return;
        }
        if ( Shieldforge_Settings::get( 'intel_apply_rules', true ) && ! empty( $payload['rules'] ) ) {
            self::apply_rules( $payload['rules'] );
        }
        if ( Shieldforge_Settings::get( 'intel_block_enabled', false ) && ! empty( $payload['added'] ) ) {
            self::apply_indicators( $payload['added'] );
        }
        if ( Shieldforge_Settings::get( 'intel_immunize', true ) && ! empty( $payload['immun_rules'] ) ) {
            self::apply_immun_rules( $payload['immun_rules'] );
        }
    }

    /** Value of the request surface a rule targets (path|query|ua|header|body). */
    private static function surface_value( $target ) {
        $uri = isset( $_SERVER['REQUEST_URI'] )
            ? urldecode( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) : '';
        switch ( $target ) {
            case 'path':
                $p = wp_parse_url( $uri, PHP_URL_PATH );
                return is_string( $p ) ? $p : $uri;
            case 'query':
                $q = wp_parse_url( $uri, PHP_URL_QUERY );
                return is_string( $q ) ? $q : '';
            case 'ua':
                return isset( $_SERVER['HTTP_USER_AGENT'] )
                    ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
            case 'header':
                $parts = array();
                foreach ( array( 'HTTP_USER_AGENT', 'HTTP_REFERER', 'HTTP_X_FORWARDED_FOR', 'HTTP_AUTHORIZATION' ) as $k ) {
                    if ( isset( $_SERVER[ $k ] ) ) {
                        $parts[] = sanitize_text_field( wp_unslash( $_SERVER[ $k ] ) );
                    }
                }
                return implode( "\n", $parts );
            case 'body':
                $b = file_get_contents( 'php://input' );
                return is_string( $b ) ? substr( $b, 0, 16384 ) : '';
            default:
                return $uri;
        }
    }

    /** Apply signed virtual-patch WAF rules; first match blocks. */
    private static function apply_rules( $rules ) {
        foreach ( $rules as $r ) {
            if ( ! is_array( $r ) || empty( $r['pattern'] ) || empty( $r['target'] ) || ! empty( $r['revoked'] ) ) {
                continue;
            }
            $value = self::surface_value( (string) $r['target'] );
            if ( '' === $value ) {
                continue;
            }
            $regex = '~' . str_replace( '~', '\~', (string) $r['pattern'] ) . '~';
            // A malformed feed pattern must never break the site — suppress + skip.
            if ( 1 === @preg_match( $regex, $value ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed feed pattern must never break the site; false-return is handled as no-match.
                self::block( 'rule:' . ( isset( $r['rule_id'] ) ? $r['rule_id'] : 'unknown' ) );
            }
        }
    }

    /** Block the caller if their global IP indicator is a high-score IoC. */
    private static function apply_indicators( $indicators ) {
        if ( ! class_exists( 'Shieldforge_Sensor_Privacy' ) || ! class_exists( 'Shieldforge_Utils' ) ) {
            return;
        }
        $ip = Shieldforge_Utils::get_real_ip();
        if ( empty( $ip ) ) {
            return;
        }
        // Same g1$<epoch>$<hmac> the sensor produces, so it matches the feed
        // values for this IP within the current rotation epoch.
        $mine = Shieldforge_Sensor_Privacy::global_indicator( $ip );
        if ( '' === $mine ) {
            return;
        }
        $threshold = (float) Shieldforge_Settings::get( 'intel_block_threshold', 0.9 );
        foreach ( $indicators as $ind ) {
            if ( ! is_array( $ind ) || empty( $ind['value'] ) || ! isset( $ind['score'] ) ) {
                continue;
            }
            if ( (string) $ind['value'] === $mine && (float) $ind['score'] >= $threshold ) {
                self::block( 'ip_reputation' );
            }
        }
    }

    /**
     * Apply signed "immun" (network-maturing) rules — a safety net that only
     * ever blocks once a rule has proven itself (stage=active), and even then
     * never against a trusted logged-in editor/admin. Shadow/canary stages
     * never block; they only record local evidence (shieldforge_immun_counters)
     * that feeds back into the hub's maturity model via flush_immun().
     */
    private static function apply_immun_rules( array $rules ) {
        if ( ! class_exists( 'Shieldforge_Sensor_Privacy' ) ) {
            return;
        }
        $reg = Shieldforge_Sensor_Privacy::get_registration();
        if ( empty( $reg['site_id'] ) ) {
            return; // no participation → this safety net has nothing to report against, do nothing.
        }
        $site_id       = (string) $reg['site_id'];
        $block_enabled = (bool) Shieldforge_Settings::get( 'intel_block_enabled', false );
        $trusted       = self::is_trusted_user();

        foreach ( $rules as $r ) {
            if ( ! is_array( $r ) || empty( $r['key'] ) || empty( $r['pattern'] ) || empty( $r['target'] ) ) {
                continue;
            }
            $rule_key = (string) $r['key'];
            $stage    = isset( $r['stage'] ) ? (string) $r['stage'] : 'shadow';
            if ( ! in_array( $stage, array( 'shadow', 'canary', 'active' ), true ) ) {
                $stage = 'shadow'; // unknown/unexpected stage value → never block on it.
            }
            $value = self::surface_value( (string) $r['target'] );
            if ( '' === $value ) {
                continue;
            }
            $regex = '~' . str_replace( '~', '\~', (string) $r['pattern'] ) . '~';
            if ( 1 !== @preg_match( $regex, $value ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed feed pattern must never break the site; false-return is handled as no-match.
                continue;
            }

            // FP guard: a trusted, authenticated editor is not a real attacker —
            // record the suspected false positive and never block on this match.
            if ( $trusted ) {
                self::bump_immun_counter( $rule_key, 'fp' );
                continue;
            }

            $mode = 'observe';
            if ( 'active' === $stage ) {
                $mode = 'block';
            } elseif ( 'canary' === $stage ) {
                $pct = isset( $r['canary_pct'] ) ? max( 0, min( 100, (int) $r['canary_pct'] ) ) : 0;
                $hash      = hash( 'sha256', $site_id . '|' . $rule_key );
                $in_cohort = ( hexdec( substr( $hash, 0, 8 ) ) % 100 ) < $pct;
                $mode      = $in_cohort ? 'block' : 'observe';
            }

            if ( 'block' === $mode && $block_enabled ) {
                // Active blocks are proven rules and are NOT reported to the
                // hub (contract); canary blocks still count as cohort evidence.
                if ( 'canary' === $stage ) {
                    self::bump_immun_counter( $rule_key, 'canary' );
                }
                self::block( 'immun:' . $rule_key );
            } else {
                // Shadow, canary-not-in-cohort, or blocking globally disabled —
                // this is the evidence path the hub's maturity model needs.
                self::bump_immun_counter( $rule_key, 'shadow' );
            }
        }
    }

    /** Trusted-editor FP guard — only trust auth state once 'init' has actually fired. */
    private static function is_trusted_user() {
        if ( ! did_action( 'init' ) || ! function_exists( 'is_user_logged_in' ) ) {
            return false;
        }
        return is_user_logged_in() && current_user_can( 'edit_posts' );
    }

    /** Read-modify-write bump of one immun rule's local hit counter. */
    private static function bump_immun_counter( $rule_key, $type ) {
        $counters = get_option( self::IMMUN_COUNTERS_OPTION, array() );
        if ( ! is_array( $counters ) ) {
            $counters = array();
        }
        if ( ! isset( $counters[ $rule_key ] ) || ! is_array( $counters[ $rule_key ] ) ) {
            $counters[ $rule_key ] = array( 'shadow' => 0, 'canary' => 0, 'fp' => 0 );
        }
        $counters[ $rule_key ][ $type ] = (int) $counters[ $rule_key ][ $type ] + 1;

        // Bound unbounded growth: drop the oldest distinct rule_keys first.
        if ( count( $counters ) > self::IMMUN_COUNTERS_MAX_KEYS ) {
            $counters = array_slice( $counters, -1 * self::IMMUN_COUNTERS_MAX_KEYS, null, true );
        }
        update_option( self::IMMUN_COUNTERS_OPTION, $counters, false );
    }

    /* ------------------------------------------------------------------
     * Immun telemetry flush (POST /v1/immun/report)
     * ------------------------------------------------------------------ */

    /** Flush queued local immun-rule counters to the hub; same cadence/gate as the sensor flush. */
    public static function flush_immun() {
        if ( ! class_exists( 'Shieldforge_Sensor_Privacy' ) || ! class_exists( 'Shieldforge_Sensor_Transport' ) ) {
            return;
        }
        if ( ! Shieldforge_Sensor_Privacy::consent_given() || ! Shieldforge_Sensor_Privacy::is_registered() ) {
            return;
        }
        $reg = Shieldforge_Sensor_Privacy::get_registration();
        if ( empty( $reg['site_id'] ) || empty( $reg['secret'] ) ) {
            return;
        }

        $counters = get_option( self::IMMUN_COUNTERS_OPTION, array() );
        if ( ! is_array( $counters ) || empty( $counters ) ) {
            return;
        }

        $reports = array();
        $flushed = array();
        foreach ( $counters as $rule_key => $c ) {
            if ( count( $reports ) >= self::IMMUN_REPORT_BATCH ) {
                break;
            }
            $shadow = is_array( $c ) && isset( $c['shadow'] ) ? (int) $c['shadow'] : 0;
            $canary = is_array( $c ) && isset( $c['canary'] ) ? (int) $c['canary'] : 0;
            $fp     = is_array( $c ) && isset( $c['fp'] ) ? (int) $c['fp'] : 0;
            if ( 0 === $shadow && 0 === $canary && 0 === $fp ) {
                continue; // all-zero row, nothing to report.
            }
            $reports[] = array(
                'rule_key'     => substr( (string) $rule_key, 0, 128 ),
                'shadow_hits'  => $shadow,
                'canary_hits'  => $canary,
                'fp_suspected' => $fp,
            );
            $flushed[ $rule_key ] = array( 'shadow' => $shadow, 'canary' => $canary, 'fp' => $fp );
        }
        if ( empty( $reports ) ) {
            return;
        }

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

        $resp = wp_remote_post(
            Shieldforge_Sensor_Transport::api_base() . '/v1/immun/report',
            array(
                'timeout' => 15,
                'headers' => array(
                    'Content-Type'   => 'application/json',
                    'X-SF-Site'      => $reg['site_id'],
                    'X-SF-Timestamp' => $ts,
                    'X-SF-Signature' => $sig,
                ),
                'body'    => $body,
            )
        );
        if ( is_wp_error( $resp ) ) {
            return; // keep counters, retry on the next tick.
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );
        if ( $code < 200 || $code >= 300 ) {
            return; // keep counters, retry on the next tick.
        }

        // Success: subtract exactly what we flushed, keeping any increments
        // that arrived from live traffic while the request was in flight.
        $current = get_option( self::IMMUN_COUNTERS_OPTION, array() );
        if ( ! is_array( $current ) ) {
            $current = array();
        }
        foreach ( $flushed as $rule_key => $amt ) {
            if ( ! isset( $current[ $rule_key ] ) || ! is_array( $current[ $rule_key ] ) ) {
                continue;
            }
            foreach ( array( 'shadow', 'canary', 'fp' ) as $t ) {
                $current[ $rule_key ][ $t ] = max( 0, (int) $current[ $rule_key ][ $t ] - (int) $amt[ $t ] );
            }
            if ( 0 === $current[ $rule_key ]['shadow'] && 0 === $current[ $rule_key ]['canary'] && 0 === $current[ $rule_key ]['fp'] ) {
                unset( $current[ $rule_key ] );
            }
        }
        update_option( self::IMMUN_COUNTERS_OPTION, $current, false );
    }

    private static function block( $reason ) {
        if ( class_exists( 'Shieldforge_Logger' ) ) {
            Shieldforge_Logger::log( 'intel_block', array(
                'reason'   => $reason,
                'severity' => 'high',
            ) );
        }
        status_header( 403 );
        nocache_headers();
        header( 'Content-Type: text/plain; charset=UTF-8' );
        header( 'X-ShieldForge-Intel-Block: 1' );
        echo "403 Forbidden\n\nRequest blocked by network threat intelligence.\n";
        exit;
    }

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

    public static function status() {
        $p = self::feed();
        return array(
            'has_feed'     => (bool) $p,
            'verified'     => (bool) $p, // feed() only returns on a good signature
            'feed_version' => $p && isset( $p['feed_version'] ) ? (int) $p['feed_version'] : 0,
            'rules'        => $p && isset( $p['rules'] ) ? count( (array) $p['rules'] ) : 0,
            'indicators'   => $p && isset( $p['added'] ) ? count( (array) $p['added'] ) : 0,
            'generated_at' => $p && isset( $p['generated_at'] ) ? (string) $p['generated_at'] : '',
        );
    }

    /** Read-only view for the admin "Immunisierungen" panel — logic-light, view just escapes. */
    public static function immun_status() {
        $p     = self::feed();
        $rules = ( $p && ! empty( $p['immun_rules'] ) ) ? (array) $p['immun_rules'] : array();

        $counters = get_option( self::IMMUN_COUNTERS_OPTION, array() );
        if ( ! is_array( $counters ) ) {
            $counters = array();
        }

        $participation = class_exists( 'Shieldforge_Sensor_Privacy' )
            && Shieldforge_Sensor_Privacy::consent_given()
            && Shieldforge_Sensor_Privacy::is_registered();

        return array(
            'rules'    => $rules,
            'counters' => $counters,
            'settings' => array(
                'immunize_enabled' => (bool) Shieldforge_Settings::get( 'intel_immunize', true ),
                'block_enabled'    => (bool) Shieldforge_Settings::get( 'intel_block_enabled', false ),
                'participation'    => (bool) $participation,
            ),
        );
    }
}
