<?php
/**
 * Anomaly engine: per-user baselines (Welford running mean+variance) and
 * Z-score-based scoring across multiple metrics. Explainable, no ML, single-table.
 *
 * Phase 3: live scoring on wp_login + multi-metric aggregation + EVT_ANOMALY logging
 *          with full explanation context.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Anomaly {

    /** Metric keys — kept stable so baseline rows stay valid across releases. */
    const METRIC_LOGIN_HOUR = 'login_hour';
    const METRIC_IP_FAMILY  = 'ip_family';
    const METRIC_UA_CLASS   = 'ua_class';

    public static function register( Shieldforge_Loader $loader ) {
        $loader->add_action( 'wp_login', __CLASS__, 'on_login_success', 20, 2 );
    }

    public static function is_enabled() {
        return (bool) Shieldforge_Settings::get( 'anomaly_enabled', false );
    }

    /**
     * Welford online update of per-user baseline for one metric.
     */
    public static function record( $user_id, $metric_key, $numeric_value ) {
        global $wpdb;
        $user_id = (int) $user_id;
        $key     = sanitize_key( $metric_key );
        if ( $user_id <= 0 || '' === $key ) {
            return false;
        }
        $table = Shieldforge_Utils::table( 'baseline' );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user values use $wpdb->prepare().
        $row   = $wpdb->get_row( $wpdb->prepare(
            "SELECT id, metric_value, sample_count FROM {$table} WHERE user_id = %d AND metric_key = %s LIMIT 1",
            $user_id, $key
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        $stats = $row ? json_decode( (string) $row->metric_value, true ) : null;
        if ( ! is_array( $stats ) ) {
            $stats = array( 'mean' => 0.0, 'm2' => 0.0 );
        }
        $count = $row ? (int) $row->sample_count : 0;

        $count++;
        $delta  = (float) $numeric_value - (float) $stats['mean'];
        $stats['mean'] += $delta / $count;
        $delta2 = (float) $numeric_value - (float) $stats['mean'];
        $stats['m2'] += $delta * $delta2;

        $payload = array(
            'metric_value' => wp_json_encode( $stats ),
            'sample_count' => $count,
            'updated_at'   => current_time( 'mysql', 1 ),
        );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        if ( $row ) {
            $wpdb->update( $table, $payload, array( 'id' => (int) $row->id ), array( '%s', '%d', '%s' ), array( '%d' ) );
        } else {
            $payload['user_id']    = $user_id;
            $payload['metric_key'] = $key;
            $wpdb->insert( $table, $payload, array( '%s', '%d', '%s', '%d', '%s' ) );
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return true;
    }

    public static function baseline( $user_id, $metric_key ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'baseline' );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user values use $wpdb->prepare().
        $row   = $wpdb->get_row( $wpdb->prepare(
            "SELECT metric_value, sample_count FROM {$table} WHERE user_id = %d AND metric_key = %s LIMIT 1",
            (int) $user_id, sanitize_key( $metric_key )
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( ! $row ) {
            return null;
        }
        $stats = json_decode( (string) $row->metric_value, true );
        if ( ! is_array( $stats ) ) {
            return null;
        }
        $count    = max( 1, (int) $row->sample_count );
        $variance = $count > 1 ? ( (float) $stats['m2'] / ( $count - 1 ) ) : 0.0;
        return array(
            'mean'     => (float) $stats['mean'],
            'variance' => $variance,
            'stddev'   => sqrt( max( 0.0, $variance ) ),
            'count'    => $count,
        );
    }

    /**
     * Returns 0..100 score derived from |Z|, capped. <30 normal, 30..70 unusual, >70 high.
     */
    public static function score( $user_id, $metric_key, $numeric_value ) {
        $baseline = self::baseline( $user_id, $metric_key );
        if ( ! $baseline || $baseline['count'] < 5 ) {
            return array(
                'score'    => 0,
                'reason'   => 'insufficient_baseline',
                'z'        => 0.0,
                'observed' => (float) $numeric_value,
                'mean'     => $baseline ? $baseline['mean'] : null,
                'stddev'   => $baseline ? $baseline['stddev'] : null,
                'count'    => $baseline ? $baseline['count'] : 0,
            );
        }
        $stddev = $baseline['stddev'];
        if ( $stddev <= 0.0001 ) {
            $delta = abs( (float) $numeric_value - $baseline['mean'] );
            return array(
                'score'    => $delta > 0 ? 80 : 0,
                'reason'   => $delta > 0 ? 'flat_baseline_diverged' : 'flat_baseline_match',
                'z'        => 0.0,
                'observed' => (float) $numeric_value,
                'mean'     => $baseline['mean'],
                'stddev'   => $stddev,
                'count'    => $baseline['count'],
            );
        }
        $z     = ( (float) $numeric_value - $baseline['mean'] ) / $stddev;
        $abs   = abs( $z );
        $score = (int) round( min( 100, $abs * 25 ) );
        return array(
            'score'    => $score,
            'reason'   => self::label_for_score( $score ),
            'z'        => round( $z, 2 ),
            'observed' => (float) $numeric_value,
            'mean'     => $baseline['mean'],
            'stddev'   => $stddev,
            'count'    => $baseline['count'],
        );
    }

    public static function label_for_score( $score ) {
        if ( $score >= 80 ) return 'highly_unusual';
        if ( $score >= 50 ) return 'unusual';
        if ( $score >= 30 ) return 'borderline';
        return 'normal';
    }

    public static function explain( $score_result ) {
        $z      = isset( $score_result['z'] ) ? (float) $score_result['z'] : 0.0;
        $reason = isset( $score_result['reason'] ) ? $score_result['reason'] : 'unknown';
        return sprintf(
            /* translators: 1: z-score, 2: classification label */
            __( 'Z-score %1$s (%2$s)', 'brainwerk-security-suite' ),
            number_format_i18n( $z, 2 ),
            $reason
        );
    }

    public static function reset_user( $user_id ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'baseline' );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        return $wpdb->delete( $table, array( 'user_id' => (int) $user_id ), array( '%d' ) );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }

    public static function reset_all() {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'baseline' );
        return $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL
    }

    /* ------------------------------------------------------------------
     * Phase 3: live scoring on every successful login.
     * ------------------------------------------------------------------ */

    /**
     * The set of metrics scored on each login. Each entry is [metric_key, observation_callable].
     */
    public static function login_metrics( $user ) {
        $ip = Shieldforge_Utils::get_real_ip();
        $ua = Shieldforge_Utils::safe_user_agent();
        return array(
            self::METRIC_LOGIN_HOUR => (int) gmdate( 'G' ),
            self::METRIC_IP_FAMILY  => self::ip_family_value( $ip ),
            self::METRIC_UA_CLASS   => self::ua_class_value( $ua ),
        );
    }

    /**
     * Hook callback: priority 20, runs after Bruteforce::on_login_success (priority 10).
     */
    public static function on_login_success( $user_login, $user ) {
        if ( ! self::is_enabled() ) {
            return;
        }
        if ( ! $user || empty( $user->ID ) ) {
            return;
        }
        $user_id = (int) $user->ID;

        $observations = self::login_metrics( $user );

        // Score BEFORE record so the new observation does not skew its own baseline.
        $per_metric = array();
        $top_score  = 0;
        $top_metric = '';
        foreach ( $observations as $metric => $value ) {
            $result            = self::score( $user_id, $metric, $value );
            $per_metric[ $metric ] = $result;
            if ( $result['score'] > $top_score ) {
                $top_score  = (int) $result['score'];
                $top_metric = $metric;
            }
        }

        // Now update baselines.
        foreach ( $observations as $metric => $value ) {
            self::record( $user_id, $metric, $value );
        }

        $alert_threshold = (int) Shieldforge_Settings::get( 'anomaly_alert_score', 50 );
        if ( $top_score < $alert_threshold ) {
            return;
        }

        $severity = Shieldforge_Logger::SEV_WARN;
        if ( $top_score >= 80 ) {
            $severity = Shieldforge_Logger::SEV_HIGH;
        }

        Shieldforge_Logger::log( Shieldforge_Logger::EVT_ANOMALY, array(
            'severity' => $severity,
            'user_id'  => $user_id,
            'username' => (string) $user_login,
            'context'  => array(
                'top_score'   => $top_score,
                'top_metric'  => $top_metric,
                'label'       => self::label_for_score( $top_score ),
                'observation' => isset( $per_metric[ $top_metric ]['observed'] ) ? $per_metric[ $top_metric ]['observed'] : null,
                'mean'        => isset( $per_metric[ $top_metric ]['mean'] ) ? $per_metric[ $top_metric ]['mean'] : null,
                'stddev'      => isset( $per_metric[ $top_metric ]['stddev'] ) ? $per_metric[ $top_metric ]['stddev'] : null,
                'z'           => isset( $per_metric[ $top_metric ]['z'] ) ? $per_metric[ $top_metric ]['z'] : 0.0,
                'sample_count' => isset( $per_metric[ $top_metric ]['count'] ) ? $per_metric[ $top_metric ]['count'] : 0,
                'metrics'     => $per_metric,
            ),
        ) );

        do_action( 'shieldforge_anomaly_detected', $user_id, $top_score, $top_metric, $per_metric );
    }

    /**
     * Convert IP to its family for baselining: 4 or 6. Returns 0 if no IP.
     */
    public static function ip_family_value( $ip ) {
        if ( ! $ip ) {
            return 0;
        }
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            return 4;
        }
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
            return 6;
        }
        return 0;
    }

    /**
     * Crude UA classification. Stable integer codes survive baseline storage.
     *  1 = desktop browser, 2 = mobile browser, 3 = cli/script, 4 = bot, 5 = unknown.
     */
    public static function ua_class_value( $ua ) {
        $ua = (string) $ua;
        if ( '' === $ua ) {
            return 5;
        }
        $low = strtolower( $ua );
        if ( preg_match( '/(curl|wget|python|go-http|java\/|libwww|powershell)/', $low ) ) {
            return 3;
        }
        if ( preg_match( '/(bot|crawl|spider|slurp|bingpreview|facebookexternalhit)/', $low ) ) {
            return 4;
        }
        if ( preg_match( '/(android|iphone|ipad|ipod|mobile)/', $low ) ) {
            return 2;
        }
        if ( preg_match( '/(mozilla|chrome|safari|firefox|edg)/', $low ) ) {
            return 1;
        }
        return 5;
    }

    public static function ua_class_label( $code ) {
        $map = array(
            0 => __( 'unknown', 'brainwerk-security-suite' ),
            1 => __( 'desktop browser', 'brainwerk-security-suite' ),
            2 => __( 'mobile browser', 'brainwerk-security-suite' ),
            3 => __( 'CLI / script', 'brainwerk-security-suite' ),
            4 => __( 'bot / crawler', 'brainwerk-security-suite' ),
            5 => __( 'unknown', 'brainwerk-security-suite' ),
        );
        $key = (int) $code;
        return isset( $map[ $key ] ) ? $map[ $key ] : __( 'unknown', 'brainwerk-security-suite' );
    }

    public static function metric_label( $metric_key ) {
        $map = array(
            self::METRIC_LOGIN_HOUR => __( 'login hour (UTC)', 'brainwerk-security-suite' ),
            self::METRIC_IP_FAMILY  => __( 'IP family (4 / 6)', 'brainwerk-security-suite' ),
            self::METRIC_UA_CLASS   => __( 'user-agent class', 'brainwerk-security-suite' ),
        );
        return isset( $map[ $metric_key ] ) ? $map[ $metric_key ] : $metric_key;
    }

    /**
     * Render observed value for a metric in a human-friendly form.
     */
    public static function metric_observed_label( $metric_key, $value ) {
        switch ( $metric_key ) {
            case self::METRIC_LOGIN_HOUR:
                return sprintf( '%02d:00 UTC', (int) $value );
            case self::METRIC_IP_FAMILY:
                $iv = (int) $value;
                return $iv === 4 ? 'IPv4' : ( $iv === 6 ? 'IPv6' : __( 'no IP', 'brainwerk-security-suite' ) );
            case self::METRIC_UA_CLASS:
                return self::ua_class_label( $value );
            default:
                return (string) $value;
        }
    }

    public static function record_login_hour( $user_id ) {
        if ( ! self::is_enabled() ) {
            return;
        }
        $hour = (int) gmdate( 'G' );
        self::record( $user_id, self::METRIC_LOGIN_HOUR, $hour );
    }

    /**
     * Recent EVT_ANOMALY rows joined with their decoded context.
     * Returns an array of stdClass with extras: ->ctx (decoded array).
     */
    public static function recent_anomalies( $limit = 20, $blog_id = null ) {
        $filters = array( 'event_type' => Shieldforge_Logger::EVT_ANOMALY );
        if ( null !== $blog_id ) {
            $filters['blog_id'] = (int) $blog_id;
        }
        $rows = Shieldforge_Logger::recent( $filters, (int) $limit, 0 );
        foreach ( $rows as $row ) {
            $decoded = json_decode( (string) $row->context, true );
            $row->ctx = is_array( $decoded ) ? $decoded : array();
        }
        return $rows;
    }
}
