<?php
/**
 * Sensor Privacy — Pfad B (Threat-Intelligence Network).
 *
 * Turns a raw internal event payload into a transport-ready SensorEvent that
 * obeys schemas/json/sensor-event.json from the shieldforge-intel repo. Three
 * jobs (RFC §3.1 Privacy.php):
 *
 *   1. Consent gate   — nothing is produced unless the admin opted in.
 *   2. Data minimisation + hashing — only whitelisted, non-identifying or
 *      hashed fields survive. The raw IP NEVER leaves the site; it becomes a
 *      global correlation indicator g1$<epoch>$<hmac> (see
 *      docs/adr-001-ip-hashing.md / ADR-001, Option A).
 *   3. Audit description — a compact "what would be sent" record for the
 *      admin-visible transparency log.
 *
 * global_epoch_key() prefers the network-distributed weekly epoch key (fetched
 * by Transport::fetch_salt() and stored via set_network_salt()) so that two
 * different registered sites produce the SAME indicator for the same IP — the
 * cross-site correlation the network effect depends on. Until a site is
 * registered (or when offline), it falls back to a local placeholder secret so
 * indicators still validate, just without cross-site correlation yet.
 *
 * The per-site sf1$ hashing in Shieldforge_Event is untouched — that stays the
 * local-storage scheme. This class is purely additive (Option C stays reachable
 * without a rewrite, exactly as ADR-001 requires).
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Sensor_Privacy {

    const SCHEMA_VERSION       = 1;
    const GLOBAL_SCHEME        = 'g1';
    const SITE_ID_OPTION       = 'shieldforge_sensor_site_id';
    const GLOBAL_SALT_OPTION   = 'shieldforge_sensor_global_salt'; // Phase-0 local placeholder.
    const REG_OPTION           = 'shieldforge_sensor_registration'; // {site_id, secret, api_base, at}
    const NETSALT_OPTION       = 'shieldforge_sensor_netsalt';      // network epoch-key bundle

    /**
     * Map of internal dotted event_type prefixes → SensorEvent.category.
     * Only network-relevant subsystems are listed; an event whose type does
     * not map here is NOT collected (data minimisation by omission).
     */
    private static $category_map = array(
        'auth'      => 'auth_failure',
        'waf'       => 'waf_block',
        'anomaly'   => 'anomaly',
        'honeypot'  => 'honeypot_hit',
        'probe'     => 'honeypot_hit',   // probing looks like a honeypot-style hit to the network
        'scan'      => 'malware_scan',
    );

    /* ------------------------------------------------------------------
     * Consent
     * ------------------------------------------------------------------ */

    /** Sensor participation is strict opt-in; default OFF. */
    public static function consent_given() {
        return (bool) Shieldforge_Settings::get( 'sensor_enabled', false );
    }

    /** Whether queued events may later be uploaded to the shared feed. */
    public static function share_with_feed_default() {
        return (bool) Shieldforge_Settings::get( 'sensor_share_with_feed', false );
    }

    /* ------------------------------------------------------------------
     * Pseudonymous identity
     * ------------------------------------------------------------------ */

    /**
     * The site id used on the wire. Prefers the SERVER-assigned id from
     * registration (Transport); falls back to a locally-generated placeholder
     * uuid so events still validate before the site has registered.
     */
    public static function site_id() {
        $reg = self::get_registration();
        if ( ! empty( $reg['site_id'] ) && self::is_uuid( $reg['site_id'] ) ) {
            return $reg['site_id'];
        }

        $id = is_multisite()
            ? get_site_option( self::SITE_ID_OPTION, '' )
            : get_option( self::SITE_ID_OPTION, '' );

        if ( is_string( $id ) && self::is_uuid( $id ) ) {
            return $id;
        }
        $id = wp_generate_uuid4();
        if ( is_multisite() ) {
            update_site_option( self::SITE_ID_OPTION, $id );
        } else {
            update_option( self::SITE_ID_OPTION, $id, false );
        }
        return $id;
    }

    /* ------------------------------------------------------------------
     * Registration + network salt (set by Transport, read here + by UI)
     * ------------------------------------------------------------------ */

    public static function get_registration() {
        $v = is_multisite() ? get_site_option( self::REG_OPTION, array() ) : get_option( self::REG_OPTION, array() );
        return is_array( $v ) ? $v : array();
    }

    public static function set_registration( $site_id, $secret, $api_base ) {
        $data = array(
            'site_id'  => (string) $site_id,
            'secret'   => (string) $secret,
            'api_base' => untrailingslashit( (string) $api_base ),
            'at'       => gmdate( 'Y-m-d\TH:i:s\Z' ),
        );
        if ( is_multisite() ) {
            update_site_option( self::REG_OPTION, $data );
        } else {
            update_option( self::REG_OPTION, $data, false );
        }
        return $data;
    }

    public static function clear_registration() {
        if ( is_multisite() ) {
            delete_site_option( self::REG_OPTION );
        } else {
            delete_option( self::REG_OPTION );
        }
    }

    public static function is_registered() {
        $reg = self::get_registration();
        return ! empty( $reg['site_id'] ) && ! empty( $reg['secret'] );
    }

    /** Store the epoch-key bundle fetched from /v1/salt/current. */
    public static function set_network_salt( array $bundle ) {
        $bundle['fetched_at'] = gmdate( 'Y-m-d\TH:i:s\Z' );
        if ( is_multisite() ) {
            update_site_option( self::NETSALT_OPTION, $bundle );
        } else {
            update_option( self::NETSALT_OPTION, $bundle, false );
        }
    }

    public static function get_network_salt() {
        $v = is_multisite() ? get_site_option( self::NETSALT_OPTION, array() ) : get_option( self::NETSALT_OPTION, array() );
        return is_array( $v ) ? $v : array();
    }

    /* ------------------------------------------------------------------
     * Global correlation indicator (ADR-001, Option A)
     * ------------------------------------------------------------------ */

    /** Current rotation epoch — ISO week, e.g. "2026-W23". */
    public static function current_epoch() {
        return gmdate( 'o-\WW' );
    }

    /**
     * Per-epoch HMAC key for the global indicator. Prefers the NETWORK epoch
     * key distributed by the central server (/v1/salt/current, stored via
     * set_network_salt) — that is what makes two different sites hash the same
     * IP to the same indicator (real cross-site correlation, ADR-001 Option A).
     *
     * Falls back to a LOCAL placeholder secret when no network salt has been
     * fetched yet (offline / not-registered). Local-keyed indicators are
     * shaped correctly but won't correlate across sites until the network salt
     * arrives — that is the expected pre-registration behaviour.
     */
    private static function global_epoch_key( $epoch ) {
        // 1. Network epoch key (current or previous epoch), if available.
        $net = self::get_network_salt();
        if ( ! empty( $net['epoch'] ) && (string) $net['epoch'] === (string) $epoch && ! empty( $net['epoch_key'] ) ) {
            return (string) $net['epoch_key'];
        }
        if ( ! empty( $net['prev_epoch'] ) && (string) $net['prev_epoch'] === (string) $epoch && ! empty( $net['prev_epoch_key'] ) ) {
            return (string) $net['prev_epoch_key'];
        }

        // 2. Local placeholder fallback (no network salt yet).
        $base = is_multisite()
            ? get_site_option( self::GLOBAL_SALT_OPTION, '' )
            : get_option( self::GLOBAL_SALT_OPTION, '' );

        if ( ! is_string( $base ) || strlen( $base ) < 32 ) {
            try {
                $base = bin2hex( random_bytes( 32 ) );
            } catch ( Exception $e ) {
                $base = wp_generate_password( 64, false, false );
            }
            if ( is_multisite() ) {
                update_site_option( self::GLOBAL_SALT_OPTION, $base );
            } else {
                update_option( self::GLOBAL_SALT_OPTION, $base, false );
            }
        }
        // Derive an epoch-scoped key so the wire key rotates even though the
        // local base secret does not.
        return hash_hmac( 'sha256', (string) $epoch, $base );
    }

    /**
     * Build the global correlation indicator for a raw IP:
     *   g1$<epoch>$<hmac_sha256(epoch_key, normalised_ip)>
     * Returns '' for empty/invalid input so the field can be omitted.
     */
    public static function global_indicator( $ip ) {
        $norm = self::normalise_ip( $ip );
        if ( '' === $norm ) {
            return '';
        }
        $epoch = self::current_epoch();
        $key   = self::global_epoch_key( $epoch );
        return self::GLOBAL_SCHEME . '$' . $epoch . '$' . hash_hmac( 'sha256', $norm, $key );
    }

    /** Canonicalise an IP (lowercase, compress v6). '' if not a valid IP. */
    public static function normalise_ip( $ip ) {
        $ip = trim( (string) $ip );
        if ( '' === $ip ) {
            return '';
        }
        $packed = @inet_pton( $ip );
        if ( false === $packed ) {
            return '';
        }
        $canon = @inet_ntop( $packed );
        return is_string( $canon ) ? strtolower( $canon ) : '';
    }

    /** Plain sha256 of the User-Agent (schema: ua_hash). '' if empty. */
    public static function ua_hash( $ua ) {
        $ua = (string) $ua;
        return '' === $ua ? '' : hash( 'sha256', $ua );
    }

    /* ------------------------------------------------------------------
     * Mapping internal payload → SensorEvent
     * ------------------------------------------------------------------ */

    /** Internal dotted event_type → SensorEvent.category, or '' if not relevant. */
    public static function map_category( $event_type ) {
        $prefix = strtok( (string) $event_type, '.' );
        return isset( self::$category_map[ $prefix ] ) ? self::$category_map[ $prefix ] : '';
    }

    /**
     * Produce a schema-conformant SensorEvent array from the raw producer
     * payload + the built internal event. Returns null when the event is not
     * network-relevant (no category mapping).
     *
     * NOTE on ai_analysis: the sensor deliberately ships verified_facts ONLY.
     * AI interpretation is a CENTRAL concern (pipeline ai-enrich, RFC §3.3) —
     * keeping the sensor fact-only is the cleanest enforcement of the
     * "verified_facts vs ai_analysis" separation and also avoids leaking the
     * local Z-score model's unbounded scores into the unit-interval schema.
     */
    public static function to_sensor_event( array $payload, array $internal_event ) {
        $event_type = isset( $payload['event_type'] ) ? (string) $payload['event_type'] : '';
        $category   = self::map_category( $event_type );
        if ( '' === $category ) {
            return null;
        }

        $actor   = isset( $payload['actor'] ) && is_array( $payload['actor'] ) ? $payload['actor'] : array();
        $target  = isset( $payload['target'] ) && is_array( $payload['target'] ) ? $payload['target'] : array();
        $context = isset( $payload['context'] ) && is_array( $payload['context'] ) ? $payload['context'] : array();

        $facts = array();

        if ( ! empty( $actor['ip'] ) ) {
            $ind = self::global_indicator( $actor['ip'] );
            if ( '' !== $ind ) {
                $facts['ip_indicator'] = $ind;
            }
        }
        if ( ! empty( $actor['user_agent'] ) ) {
            $uah = self::ua_hash( $actor['user_agent'] );
            if ( '' !== $uah ) {
                $facts['ua_hash'] = $uah;
            }
        }
        if ( ! empty( $actor['ip'] ) && class_exists( 'Shieldforge_Geo' ) ) {
            // Raw IP never leaves the site — only the resolved 2-letter code.
            $cc = Shieldforge_Geo::country_for_ip( (string) $actor['ip'] );
            if ( 'ZZ' !== $cc ) {
                $facts['geo_country'] = $cc;
            }
        } elseif ( ! empty( $actor['country'] ) ) {
            $cc = strtoupper( substr( preg_replace( '/[^A-Za-z]/', '', (string) $actor['country'] ), 0, 2 ) );
            if ( 2 === strlen( $cc ) ) {
                $facts['geo_country'] = $cc;
            }
        }
        if ( ! empty( $actor['ip'] ) && class_exists( 'Shieldforge_Asn' ) ) {
            // Raw IP never leaves the site — only the resolved ASN number (no provider name).
            $asn = Shieldforge_Asn::asn_for_ip( (string) $actor['ip'] );
            if ( null !== $asn ) {
                $facts['attacker_asn'] = $asn;
            }
        }
        // Request shape: take whatever the producer reliably provides.
        $request_path = '';
        if ( ! empty( $target['endpoint'] ) ) {
            $request_path = (string) $target['endpoint'];
        } elseif ( ! empty( $context['request_path'] ) ) {
            $request_path = (string) $context['request_path'];
        }
        if ( '' !== $request_path ) {
            $q = strpos( $request_path, '?' );
            $facts['request_path'] = $q !== false ? substr( $request_path, 0, $q ) : $request_path;
        }
        if ( ! empty( $context['method'] ) ) {
            $facts['method'] = strtoupper( substr( preg_replace( '/[^A-Za-z]/', '', (string) $context['method'] ), 0, 8 ) );
        }
        if ( isset( $context['http_status'] ) && (int) $context['http_status'] >= 100 ) {
            $facts['http_status'] = (int) $context['http_status'];
        }
        if ( ! empty( $context['rule_id'] ) ) {
            $facts['rule_id'] = substr( (string) $context['rule_id'], 0, 64 );
        }

        $facts['endpoint_class'] = self::classify_endpoint( $request_path, $context );

        if ( class_exists( 'Shieldforge_Cve_Rules' ) ) {
            $cve = Shieldforge_Cve_Rules::match( $request_path, $context );
            if ( ! empty( $cve ) ) {
                $facts['cve_id'] = substr( (string) $cve, 0, 32 );
            }
        }

        $target_slug = self::classify_target_slug( $request_path );
        if ( null !== $target_slug ) {
            $facts['target_slug'] = $target_slug;
        }

        $rule_id      = isset( $context['rule_id'] ) ? (string) $context['rule_id'] : '';
        $attack_class = self::classify_attack_class( $rule_id, $request_path, $context );
        if ( null !== $attack_class ) {
            $facts['attack_class'] = $attack_class;
        }

        $raw_ua     = isset( $actor['user_agent'] ) ? (string) $actor['user_agent'] : '';
        $tool_class = self::classify_tool_class( $raw_ua );
        if ( null !== $tool_class ) {
            $facts['tool_class'] = $tool_class;
        }

        $bot_family = self::classify_bot_family( $raw_ua );
        if ( null !== $bot_family ) {
            $facts['bot_family'] = $bot_family;
        }

        $ts = isset( $internal_event['occurred_at'] ) && is_string( $internal_event['occurred_at'] )
            ? $internal_event['occurred_at']
            : gmdate( 'Y-m-d\TH:i:s\Z' );

        $event = array(
            'schema_version' => self::SCHEMA_VERSION,
            'site_id'        => self::site_id(),
            'event_id'       => wp_generate_uuid4(), // idempotency key for the network
            'ts'             => $ts,
            'category'       => $category,
            'verified_facts' => $facts,
        );

        // Which individual site within the instance observed this. On multisite
        // that is the sub-site host; on a single site it's just this site. Lets
        // the central cockpit break an instance down per sub-site.
        $node = self::node_host( $internal_event );
        if ( '' !== $node ) {
            $event['node'] = $node;
        }
        return $event;
    }

    /**
     * Classify a request path (+ context) into exactly one coarse endpoint
     * class. Used only to enrich verified_facts — never affects blocking.
     *
     * @param string $path_and_query the raw request path, possibly with a query string
     * @param array  $context        producer context (may contain http_status)
     * @return string one of: login, xmlrpc, rest, admin, cron, config_probe,
     *                 plugin_exploit, theme_exploit, upload_probe, enum,
     *                 generic_404, other
     */
    public static function classify_endpoint( $path_and_query, array $context = array() ) {
        $raw  = (string) $path_and_query;
        $qpos = strpos( $raw, '?' );
        $path = $qpos !== false ? substr( $raw, 0, $qpos ) : $raw;
        $query = $qpos !== false ? substr( $raw, $qpos + 1 ) : '';

        if ( '' === $path && '' === $query ) {
            return 'other';
        }

        if ( false !== stripos( $path, 'wp-login.php' ) || false !== stripos( $query, 'loginout' ) ) {
            return 'login';
        }
        if ( false !== stripos( $path, 'xmlrpc.php' ) ) {
            return 'xmlrpc';
        }
        if ( false !== stripos( $path, '/wp-json' ) || false !== stripos( $query, 'rest_route' ) ) {
            if ( false !== stripos( $path, '/wp/v2/users' ) || preg_match( '/(?:^|[?&])author=\d+/i', $query ) ) {
                return 'enum';
            }
            return 'rest';
        }
        if ( preg_match( '/(?:^|[?&])author=\d+/i', $query ) ) {
            return 'enum';
        }
        if ( false !== stripos( $path, '/wp-admin' ) ) {
            return 'admin';
        }
        if ( false !== stripos( $path, 'wp-cron.php' ) ) {
            return 'cron';
        }
        if ( preg_match( '/\.env$|wp-config\.bak|\.git\/|id_rsa|backup[^\/]*\.zip|\.sql$/i', $path ) ) {
            return 'config_probe';
        }
        if ( preg_match( '#/wp-content/plugins/([^/]+)/#i', $path, $m ) ) {
            if ( preg_match( '/\.php($|[\/?])/i', $path ) || preg_match( '/[?&](cmd|exec|shell|action)=/i', $query ) ) {
                return 'plugin_exploit';
            }
        }
        if ( false !== stripos( $path, '/wp-content/themes/' ) ) {
            return 'theme_exploit';
        }
        if ( preg_match( '#/wp-content/uploads/.*\.php($|[\/?])#i', $path ) ) {
            return 'upload_probe';
        }
        if ( isset( $context['http_status'] ) && 404 === (int) $context['http_status'] ) {
            return 'generic_404';
        }
        return 'other';
    }

    /**
     * Extract the attacked plugin/theme directory slug from a request path,
     * e.g. /wp-content/plugins/foo-bar/inc/x.php -> "foo-bar". Sanitised hard
     * so only a public directory slug can ever leave the site — never a full
     * file path.
     *
     * @return string|null lowercase [a-z0-9-]{1,60} slug, or null if the path
     *                      doesn't look like a plugin/theme request.
     */
    public static function classify_target_slug( $path_and_query ) {
        $raw  = (string) $path_and_query;
        $qpos = strpos( $raw, '?' );
        $path = $qpos !== false ? substr( $raw, 0, $qpos ) : $raw;

        if ( ! preg_match( '#/wp-content/(?:plugins|themes)/([^/]+)#i', $path, $m ) ) {
            return null;
        }
        $slug = strtolower( $m[1] );
        $slug = preg_replace( '/[^a-z0-9-]/', '', $slug );
        $slug = substr( (string) $slug, 0, 60 );
        return '' === $slug ? null : $slug;
    }

    /**
     * Map a WAF rule_id (see Shieldforge_Waf::patterns()) to a coarse
     * OWASP-style attack_class. Rule ids are namespaced by prefix
     * (sqli_/xss_/lfi_/rce_/ssrf_/xxe_/cmdi_/wp_/scanner_) so the mapping is a
     * direct lookup on that prefix, not a re-derivation of the WAF logic.
     */
    private static $waf_rule_attack_class = array(
        'sqli_union'       => 'sqli',
        'sqli_or_1eq1'     => 'sqli',
        'sqli_sleep'       => 'sqli',
        'sqli_drop'        => 'sqli',
        'sqli_load'        => 'sqli',
        'sqli_concat'      => 'sqli',
        'sqli_information' => 'sqli',
        'sqli_comment'     => 'sqli',
        'xss_script_tag'   => 'xss',
        'xss_javascript'   => 'xss',
        'xss_onevent'      => 'xss',
        'xss_svg_script'   => 'xss',
        'xss_iframe'       => 'xss',
        'xss_data_uri'     => 'xss',
        'lfi_traversal'    => 'traversal',
        'lfi_etcpasswd'    => 'lfi',
        'lfi_proc'         => 'lfi',
        'lfi_wp_config'    => 'traversal',
        'lfi_php_wrapper'  => 'lfi',
        'lfi_data_wrapper' => 'lfi',
        'lfi_zip_wrapper'  => 'object_injection', // phar:// is a deserialisation vector.
        'rce_eval'         => 'rce',
        'rce_system'       => 'rce',
        'rce_base64_long'  => 'rce',
        'rce_create_func'  => 'rce',
        'ssrf_file'        => 'ssrf',
        'ssrf_gopher'      => 'ssrf',
        'ssrf_dict'        => 'ssrf',
        'ssrf_internal_ip' => 'ssrf',
        'cmdi_shell_meta'  => 'rce',
        'wp_xmlrpc_brute'  => 'auth_bypass',
    );

    /**
     * Classify the attack technique. Prefers a direct WAF rule_id -> class
     * lookup (self::$waf_rule_attack_class); falls back to a path/context
     * heuristic for events that carry no WAF rule_id (honeypot, anomaly,
     * probe). Returns null (omit the fact) when confidence is low — never
     * defaults to 'other'.
     *
     * @return string|null one of sqli, xss, lfi, rce, traversal, auth_bypass,
     *                     ssrf, object_injection, file_upload, other; or null.
     */
    public static function classify_attack_class( $rule_id, $path_and_query, array $context = array() ) {
        $rule_id = (string) $rule_id;
        if ( isset( self::$waf_rule_attack_class[ $rule_id ] ) ) {
            return self::$waf_rule_attack_class[ $rule_id ];
        }

        $raw  = (string) $path_and_query;
        $qpos = strpos( $raw, '?' );
        $path = $qpos !== false ? substr( $raw, 0, $qpos ) : $raw;
        $query = $qpos !== false ? substr( $raw, $qpos + 1 ) : '';
        $hay  = $rule_id . ' ' . $path . ' ' . $query;

        if ( preg_match( '/union\s+select|\bsqli\b/i', $hay ) ) {
            return 'sqli';
        }
        if ( preg_match( '/<script|onerror\s*=|javascript\s*:/i', $hay ) ) {
            return 'xss';
        }
        if ( preg_match( '/wp[-_]config|\.env\b/i', $hay ) ) {
            return 'traversal';
        }
        if ( preg_match( '/\.\.[\/\\\\]|\/etc\/passwd/i', $hay ) ) {
            return 'lfi';
        }
        if ( preg_match( '/base64_decode\s*\(|\beval\s*\(|\bsystem\s*\(/i', $hay ) ) {
            return 'rce';
        }
        if ( preg_match( '/phar:\/\/|unserialize\s*\(/i', $hay ) ) {
            return 'object_injection';
        }
        if ( preg_match( '#/wp-content/uploads/.*\.php($|[\/?])#i', $path ) ) {
            return 'file_upload';
        }
        if ( preg_match( '/xmlrpc\.php/i', $path ) && preg_match( '/pingback/i', $hay ) ) {
            return 'ssrf';
        }
        if ( preg_match( '/wp-login\.php/i', $path ) && isset( $context['http_status'] ) && in_array( (int) $context['http_status'], array( 401, 403 ), true ) ) {
            return 'auth_bypass';
        }

        return null;
    }

    /**
     * Classify the attacker tool/scanner family from the RAW User-Agent. Only
     * the resulting class ever leaves the site — the raw UA stays local (it
     * is also separately one-way hashed into verified_facts.ua_hash).
     *
     * @return string|null one of wpscan, sqlmap, nuclei, nikto, curl, wget,
     *                     python_requests, go_http, libwww, headless_chrome,
     *                     generic_bot, other; or null for an ordinary browser.
     */
    public static function classify_tool_class( $user_agent ) {
        $ua = trim( (string) $user_agent );
        if ( '' === $ua ) {
            return 'generic_bot';
        }

        $map = array(
            'wpscan'           => 'wpscan',
            'sqlmap'           => 'sqlmap',
            'nuclei'           => 'nuclei',
            'nikto'            => 'nikto',
            'headlesschrome'   => 'headless_chrome',
            'python-requests'  => 'python_requests',
            'go-http-client'   => 'go_http',
            'libwww-perl'      => 'libwww',
            'curl/'            => 'curl',
            'wget/'            => 'wget',
        );
        $ua_lower = strtolower( $ua );
        foreach ( $map as $needle => $class ) {
            if ( false !== strpos( $ua_lower, $needle ) ) {
                return $class;
            }
        }

        // Ordinary browser UA — not an identifiable tool, omit the fact.
        if ( preg_match( '/mozilla\/[\d.]+.*\b(chrome|crios|firefox|fxios|safari|edg|opr)\b/i', $ua )
            && false === strpos( $ua_lower, 'headless' ) ) {
            return null;
        }

        return 'other';
    }

    /**
     * Known bot/crawler User-Agent tokens -> canonical family name. Ordered
     * so a more specific token (e.g. 'applebot-extended') is tested before a
     * shorter token it contains (e.g. 'applebot'), since matching is a plain
     * substring search. Single source of truth — reuse this instead of
     * duplicating the list elsewhere.
     *
     * Covers the AI/LLM crawlers the hub joins against the SEO Suite's
     * declared-identity classification, plus the mainstream search engines.
     */
    private static $bot_family_tokens = array(
        // AI / LLM crawlers (specific-before-generic where tokens overlap).
        'applebot-extended'   => 'Applebot-Extended',
        'gptbot'              => 'GPTBot',
        'oai-searchbot'       => 'OAI-SearchBot',
        'chatgpt-user'        => 'ChatGPT-User',
        'claude-searchbot'    => 'Claude-SearchBot',
        'claude-user'         => 'Claude-User',
        'claudebot'           => 'ClaudeBot',
        'anthropic-ai'        => 'anthropic-ai',
        'perplexity-user'     => 'Perplexity-User',
        'perplexitybot'       => 'PerplexityBot',
        'google-extended'     => 'Google-Extended',
        'bytespider'          => 'Bytespider',
        'ccbot'               => 'CCBot',
        'amazonbot'           => 'Amazonbot',
        'meta-externalagent'  => 'Meta-ExternalAgent',
        'cohere-ai'           => 'cohere-ai',
        'diffbot'             => 'Diffbot',
        'imagesiftbot'        => 'ImagesiftBot',
        'youbot'              => 'YouBot',
        // Mainstream search-engine crawlers.
        'googlebot'           => 'Googlebot',
        'bingbot'             => 'Bingbot',
        'slurp'               => 'Slurp',
        'duckduckbot'         => 'DuckDuckBot',
        'baiduspider'         => 'Baiduspider',
        'yandexbot'           => 'YandexBot',
        'applebot'            => 'Applebot',
        'facebookexternalhit' => 'facebookexternalhit',
        'sogou'               => 'Sogou',
        'exabot'              => 'Exabot',
    );

    /**
     * Classify the request's DECLARED bot/crawler identity from its raw
     * User-Agent — i.e. which family it claims to be, not a judgement on
     * whether that claim is true. Only the canonical family name ever
     * leaves the site (verified_facts.bot_family); the raw UA never does
     * (it stays local, and is separately one-way hashed into ua_hash).
     *
     * Returns null (fact omitted) rather than an 'unknown' sentinel when
     * the UA matches no known family: an omitted field cleanly means "not
     * a recognised crawler", matching how every other optional fact in
     * this file is handled (classify_attack_class, target_slug, cve_id, …)
     * and keeping the wire vocabulary a strict, reviewable allow-list.
     *
     * @return string|null canonical family name (see self::$bot_family_tokens), or null.
     */
    public static function classify_bot_family( $user_agent ) {
        $ua = trim( (string) $user_agent );
        if ( '' === $ua ) {
            return null;
        }

        $ua_lower = strtolower( $ua );
        foreach ( self::$bot_family_tokens as $needle => $family ) {
            if ( false !== strpos( $ua_lower, $needle ) ) {
                return $family;
            }
        }

        return null;
    }

    /** Host of the (sub-)site that observed the event, from the captured blog_id. */
    private static function node_host( array $internal_event ) {
        $bid = isset( $internal_event['site']['blog_id'] ) ? (int) $internal_event['site']['blog_id'] : 0;
        $url = ( $bid > 0 && function_exists( 'get_home_url' ) ) ? get_home_url( $bid ) : home_url();
        $host = (string) wp_parse_url( $url, PHP_URL_HOST );
        return substr( $host, 0, 255 );
    }

    /* ------------------------------------------------------------------
     * Transparency
     * ------------------------------------------------------------------ */

    /**
     * Compact, non-identifying descriptor of a SensorEvent for the admin
     * "what gets sent" log. Lists which fields are present and shows only a
     * truncated indicator — never raw values (there are none anyway).
     */
    public static function describe_for_audit( array $sensor_event ) {
        $facts  = isset( $sensor_event['verified_facts'] ) ? $sensor_event['verified_facts'] : array();
        $ind    = isset( $facts['ip_indicator'] ) ? (string) $facts['ip_indicator'] : '';
        $ind_sn = '' !== $ind ? substr( $ind, 0, 20 ) . '…' : '';

        return array(
            'ts'             => isset( $sensor_event['ts'] ) ? $sensor_event['ts'] : gmdate( 'Y-m-d\TH:i:s\Z' ),
            'category'       => isset( $sensor_event['category'] ) ? $sensor_event['category'] : '',
            'fields'         => array_keys( $facts ),
            'ip_indicator'   => $ind_sn,
            'has_ai'         => false, // sensor never attaches ai_analysis (see to_sensor_event)
        );
    }

    /* ------------------------------------------------------------------
     * helpers
     * ------------------------------------------------------------------ */

    public static function is_uuid( $v ) {
        return is_string( $v ) && preg_match(
            '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
            $v
        );
    }
}
