<?php
/**
 * Typed event helper — Phase 7 Stage 2.
 *
 * Companion to the legacy Shieldforge_Logger. The Logger writes
 * application-friendly rows (joinable on raw IP/username) into
 * wp_shieldforge_log; this class produces *transport-ready* events that
 * obey the schema in docs/event-schema.md (every identifier is either
 * non-identifying or a sf1$-salted-sha256 hash) and stores them in a
 * separate table.
 *
 * Stage-2 scope:
 *   - per-site salt bootstrap (auto-generated in get_salt() if missing)
 *   - sf1$-hashing
 *   - event builder + persistence in wp_shieldforge_events
 *   - first producer: auth.bf_lockout (wired in class-shieldforge-bruteforce)
 *
 * Out of scope (Stage 3):
 *   - threat-feed upload (the share_with_feed flag is set per-event but
 *     nothing reads it yet)
 *   - typed events for the other producers (auditlog, anomaly, scanner)
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Event {

    const SCHEMA_VERSION = 1;
    const HASH_PREFIX    = 'sf1$';
    const SALT_OPTION    = 'shieldforge_site_salt';

    /**
     * Return the per-site salt, generating one once on first use.
     * Stored in site-options on multisite (network-wide), regular options
     * otherwise. NEVER rotated — rotation would invalidate every historical
     * hash and break cross-event correlation.
     */
    public static function get_salt() {
        $salt = is_multisite()
            ? get_site_option( self::SALT_OPTION, '' )
            : get_option( self::SALT_OPTION, '' );

        if ( is_string( $salt ) && strlen( $salt ) >= 32 ) {
            return $salt;
        }

        // First-use bootstrap. Use the CSPRNG; fall back to wp_generate_password
        // (also random_bytes-backed since WP 4.4) to keep this dependency-free.
        try {
            $salt = bin2hex( random_bytes( 32 ) );
        } catch ( Exception $e ) {
            $salt = wp_generate_password( 64, false, false );
        }

        if ( is_multisite() ) {
            update_site_option( self::SALT_OPTION, $salt );
        } else {
            update_option( self::SALT_OPTION, $salt, false ); // no autoload
        }
        return $salt;
    }

    /**
     * Hash a value into "sf1$<lowercase-hex sha256>" form.
     * Empty / null inputs return an empty string so the field can be
     * omitted from the event without nulling the schema.
     */
    public static function hash_value( $value ) {
        if ( null === $value ) return '';
        $value = (string) $value;
        if ( '' === $value ) return '';
        $salt = self::get_salt();
        return self::HASH_PREFIX . hash( 'sha256', $salt . ':' . $value );
    }

    /**
     * Generate an event_id in the form "evt_<ISO8601Z>_<8-hex>".
     */
    public static function new_event_id() {
        $when = gmdate( 'Y-m-d\TH:i:s\Z' );
        try {
            $rand = bin2hex( random_bytes( 4 ) );
        } catch ( Exception $e ) {
            $rand = substr( md5( uniqid( '', true ) ), 0, 8 );
        }
        return 'evt_' . $when . '_' . $rand;
    }

    /**
     * Classify a User-Agent string into a coarse bucket. We deliberately
     * do not fingerprint specific browsers; the schema only commits to
     * four buckets.
     */
    public static function classify_user_agent( $ua ) {
        $ua = (string) $ua;
        if ( '' === $ua ) {
            return 'unknown';
        }
        $u = strtolower( $ua );
        // automation tooling that doesn't pretend to be a browser
        if ( preg_match( '/(curl|wget|python-requests|httpx|libwww-perl|go-http-client|java\/|okhttp|httpclient|axios|node-fetch|powershell|bash)/i', $ua ) ) {
            return 'automation';
        }
        // declared bots (well-behaved crawlers identify themselves)
        if ( false !== strpos( $u, 'bot' ) || false !== strpos( $u, 'spider' ) || false !== strpos( $u, 'crawler' ) ) {
            return 'bot-friendly';
        }
        if ( preg_match( '/(mozilla|chrome|safari|firefox|edge|opera)/i', $ua ) ) {
            return 'human';
        }
        return 'unknown';
    }

    /**
     * Coarse IP family detector. Returns 'v4', 'v6', or '' for unknown.
     */
    public static function ip_family( $ip ) {
        if ( ! is_string( $ip ) || '' === $ip ) return '';
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) return 'v4';
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) return 'v6';
        return '';
    }

    /**
     * Build a typed Event from raw producer data. Producers pass plain
     * values (IPs, usernames, paths) and this method hashes the
     * identifying ones via hash_value().
     *
     * $payload schema:
     *   event_type   (string, required)  e.g. 'auth.bf_lockout'
     *   severity     (string, default 'info')
     *   actor.ip                (string|null)
     *   actor.country           (string|null)
     *   actor.user_agent        (string|null)
     *   actor.username          (string|null)
     *   target.kind             (string|null)
     *   target.user_id          (int|null)
     *   target.user_role        (string|null)
     *   target.file_path        (string|null, RELATIVE to ABSPATH)
     *   target.endpoint         (string|null)
     *   context      (array, freeform but typed by event_type)
     *   share_with_feed (bool, default false)
     */
    public static function build( array $payload ) {
        if ( empty( $payload['event_type'] ) ) {
            return null;
        }

        $event = array(
            'schema_version'  => self::SCHEMA_VERSION,
            'event_id'        => self::new_event_id(),
            'event_type'      => (string) $payload['event_type'],
            'severity'        => isset( $payload['severity'] ) ? (string) $payload['severity'] : 'info',
            'occurred_at'     => gmdate( 'Y-m-d\TH:i:s\Z' ),
            'site'            => self::build_site_block(),
            'share_with_feed' => ! empty( $payload['share_with_feed'] ),
        );

        // actor block
        $actor_in = isset( $payload['actor'] ) && is_array( $payload['actor'] ) ? $payload['actor'] : array();
        $actor    = array();
        if ( ! empty( $actor_in['ip'] ) ) {
            $actor['ip_hash']   = self::hash_value( $actor_in['ip'] );
            $actor['ip_family'] = self::ip_family( $actor_in['ip'] );
        }
        if ( ! empty( $actor_in['country'] ) ) {
            $actor['country'] = strtoupper( substr( (string) $actor_in['country'], 0, 2 ) );
        }
        if ( ! empty( $actor_in['user_agent'] ) ) {
            $actor['ua_class'] = self::classify_user_agent( $actor_in['user_agent'] );
        }
        if ( ! empty( $actor_in['username'] ) ) {
            $actor['username_hash'] = self::hash_value( strtolower( (string) $actor_in['username'] ) );
        }
        if ( ! empty( $actor ) ) {
            $event['actor'] = $actor;
        }

        // target block
        $target_in = isset( $payload['target'] ) && is_array( $payload['target'] ) ? $payload['target'] : array();
        $target    = array();
        if ( ! empty( $target_in['kind'] ) )       { $target['kind']      = (string) $target_in['kind']; }
        if ( ! empty( $target_in['user_id'] ) )   { $target['user_hash'] = self::hash_value( (int) $target_in['user_id'] ); }
        if ( ! empty( $target_in['user_role'] ) ) { $target['user_role'] = (string) $target_in['user_role']; }
        if ( ! empty( $target_in['file_path'] ) ) { $target['file_path'] = self::relative_path( $target_in['file_path'] ); }
        if ( ! empty( $target_in['endpoint'] ) )  { $target['endpoint']  = self::strip_query( $target_in['endpoint'] ); }
        if ( ! empty( $target ) ) {
            $event['target'] = $target;
        }

        // context (passes through verbatim — schema is open for context keys)
        if ( isset( $payload['context'] ) && is_array( $payload['context'] ) && ! empty( $payload['context'] ) ) {
            $event['context'] = $payload['context'];
        }

        return $event;
    }

    /**
     * Build + persist in one call. Returns the event_id on success,
     * false on failure. Producers should use this; the splitter only
     * exists for tests.
     */
    public static function emit( array $payload ) {
        $event = self::build( $payload );
        if ( null === $event ) return false;
        $stored = self::store( $event );
        if ( $stored ) {
            /**
             * Phase B (TIN) sensor hook. Fires after a typed event is stored.
             *
             * The RAW $payload is passed (not just the built $event) on purpose:
             * build() has already hashed identifying values to the per-site
             * sf1$ form for local storage, but the sensor Collector needs the
             * raw IP to compute the *global* correlation indicator
             * (g1$<epoch>$<hmac>). See includes/sensor/.
             *
             * @param array  $payload  Raw producer payload (may contain raw IP/UA).
             * @param array  $event    The built, sf1$-hashed local event.
             * @param string $event_id The local event id.
             */
            do_action( 'shieldforge_event_emitted', $payload, $event, $event['event_id'] );
        }
        return $stored ? $event['event_id'] : false;
    }

    /**
     * Persist a built event into wp_shieldforge_events. The whole event
     * is also serialized into the `payload` JSON column for forward-
     * compatibility (new schema fields don't require ALTERs).
     */
    public static function store( array $event ) {
        global $wpdb;
        $table = $wpdb->base_prefix . 'shieldforge_events';

        $occurred_at = isset( $event['occurred_at'] ) ? $event['occurred_at'] : gmdate( 'Y-m-d\TH:i:s\Z' );
        // wp_shieldforge_events.created_at is MySQL DATETIME — strip the trailing Z.
        $created_at  = str_replace( array( 'T', 'Z' ), array( ' ', '' ), $occurred_at );

        $row = array(
            'event_id'        => isset( $event['event_id'] ) ? (string) $event['event_id'] : self::new_event_id(),
            'event_type'      => isset( $event['event_type'] ) ? (string) $event['event_type'] : 'unknown',
            'severity'        => isset( $event['severity'] ) ? (string) $event['severity'] : 'info',
            'schema_version'  => isset( $event['schema_version'] ) ? (int) $event['schema_version'] : self::SCHEMA_VERSION,
            'share_with_feed' => ! empty( $event['share_with_feed'] ) ? 1 : 0,
            'blog_id'         => isset( $event['site']['blog_id'] ) ? (int) $event['site']['blog_id'] : ( is_multisite() ? get_current_blog_id() : 0 ),
            'payload'         => wp_json_encode( $event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
            'created_at'      => $created_at,
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom security table; direct live write by design; not cacheable.
        $ok = $wpdb->insert(
            $table,
            $row,
            array( '%s', '%s', '%s', '%d', '%d', '%d', '%s', '%s' )
        );
        return false !== $ok;
    }

    /**
     * SQL CREATE TABLE for use by the activator + migrations. Mirrors
     * the legacy log table's column types so wpdb sanitization works
     * the same way.
     */
    public static function table_sql() {
        global $wpdb;
        $table           = $wpdb->base_prefix . 'shieldforge_events';
        $charset_collate = $wpdb->get_charset_collate();
        return "CREATE TABLE {$table} (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            event_id VARCHAR(64) NOT NULL,
            event_type VARCHAR(64) NOT NULL,
            severity VARCHAR(16) NOT NULL DEFAULT 'info',
            schema_version SMALLINT UNSIGNED NOT NULL DEFAULT 1,
            share_with_feed TINYINT UNSIGNED NOT NULL DEFAULT 0,
            blog_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            payload LONGTEXT NOT NULL,
            created_at DATETIME NOT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY uniq_event_id (event_id),
            KEY idx_event_type (event_type),
            KEY idx_created (created_at),
            KEY idx_blog (blog_id),
            KEY idx_severity (severity)
        ) {$charset_collate};";
    }

    public static function create_table() {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( self::table_sql() );
    }

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

    private static function build_site_block() {
        $home = function_exists( 'home_url' ) ? home_url() : '';
        $site = array(
            'site_hash' => self::hash_value( $home ),
            'multisite' => is_multisite(),
        );
        if ( is_multisite() ) {
            $site['blog_id'] = (int) get_current_blog_id();
        }
        return $site;
    }

    private static function relative_path( $path ) {
        $path = (string) $path;
        if ( '' === $path ) return '';
        $abspath = defined( 'ABSPATH' ) ? ABSPATH : '';
        if ( '' !== $abspath && 0 === strpos( $path, $abspath ) ) {
            return ltrim( substr( $path, strlen( $abspath ) ), '/\\' );
        }
        return $path;
    }

    private static function strip_query( $endpoint ) {
        $endpoint = (string) $endpoint;
        $q = strpos( $endpoint, '?' );
        return ( false !== $q ) ? substr( $endpoint, 0, $q ) : $endpoint;
    }
}
