<?php
/**
 * Threat intelligence -derives a transparent threat score, trend deltas
 * and top-N tables from the local shieldforge_log table.
 *
 * Free-tier: pure local statistics, no external feeds. Components are clamped
 * and explained so admins can verify why the score is what it is.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Threat_Intel {

    const LABEL_CALM     = 'calm';
    const LABEL_ELEVATED = 'elevated';
    const LABEL_HIGH     = 'high';
    const LABEL_CRITICAL = 'critical';

    /**
     * Build the full digest payload -caller renders it.
     */
    public static function build_digest( $now = null ) {
        $now      = $now ? (int) $now : time();
        $since24  = gmdate( 'Y-m-d H:i:s', $now - DAY_IN_SECONDS );
        $since7d  = gmdate( 'Y-m-d H:i:s', $now - 7 * DAY_IN_SECONDS );

        $snapshot24  = self::compute_snapshot( $since24 );
        $snapshot7d  = self::compute_snapshot( $since7d );
        $baseline7d  = self::baseline_per_day( $snapshot7d );
        $trend       = self::compute_trend( $snapshot24, $baseline7d );
        $score       = self::compute_threat_score( $snapshot24 );

        return array(
            'now'              => $now,
            'snapshot_24h'     => $snapshot24,
            'baseline_7d_avg'  => $baseline7d,
            'trend'            => $trend,
            'score'            => $score,
            'top_attackers'    => Shieldforge_Logger::top_attacker_ips( 24, 5 ),
            'top_usernames'    => self::top_targeted_usernames( $since24, 5 ),
            'top_probing'      => self::top_probing_patterns( $since24, 5 ),
            'hourly'           => self::hourly_histogram( $now ),
            'recent_anomalies' => self::recent_anomalies( $since24, 3 ),
        );
    }

    /**
     * Aggregate event counts + distinct-IP count for a given since-window.
     */
    public static function compute_snapshot( $since_mysql ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );

        $counts = array(
            'logins_ok'        => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_LOGIN_OK ) ),
            'logins_fail'      => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_LOGIN_FAIL ) ),
            'logins_blocked'   => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_LOGIN_BLOCKED ) ),
            'lockouts'         => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_BF_LOCKOUT ) ),
            'probing_404s'     => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_PROBING_404 ) ),
            'hardening_blocks' => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_HARDENING_BLOCK ) ),
            'anomalies'        => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_ANOMALY ) ),
            'file_added'       => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_FILE_ADDED ) ),
            'file_changed'     => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_FILE_CHANGED ) ),
            'file_missing'     => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_FILE_MISSING ) ),
            'malware_suspect'  => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_MALWARE_SUSPECT ) ),
        );

        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $counts['distinct_ips'] = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(DISTINCT ip) FROM {$table} WHERE created_at >= %s AND ip <> '' AND severity >= %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $counts['total_high_severity'] = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND severity >= %d",
            $since_mysql, Shieldforge_Logger::SEV_HIGH
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        return $counts;
    }

    /**
     * Convert a 7-day total snapshot into per-day averages so it can be
     * compared against a 24h snapshot.
     */
    public static function baseline_per_day( array $snapshot7d ) {
        $out = array();
        foreach ( $snapshot7d as $key => $value ) {
            $out[ $key ] = round( ( (float) $value ) / 7.0, 1 );
        }
        return $out;
    }

    public static function compute_trend( array $current, array $baseline_per_day ) {
        $out = array();
        foreach ( $current as $key => $value ) {
            $base  = isset( $baseline_per_day[ $key ] ) ? (float) $baseline_per_day[ $key ] : 0.0;
            $delta = (float) $value - $base;
            $pct   = $base > 0 ? ( $delta / $base ) * 100.0 : ( $value > 0 ? 100.0 : 0.0 );
            $out[ $key ] = array(
                'current'  => (int) $value,
                'baseline' => $base,
                'delta'    => $delta,
                'pct'      => round( $pct, 0 ),
                'arrow'    => self::trend_arrow( $delta, $base ),
            );
        }
        return $out;
    }

    public static function trend_arrow( $delta, $base ) {
        if ( $base <= 0 && $delta <= 0 ) {
            return '=';
        }
        if ( $delta >= max( 1, $base * 0.5 ) ) {
            return '^^';
        }
        if ( $delta > 0 ) {
            return '^';
        }
        if ( $delta <= -max( 1, $base * 0.5 ) ) {
            return 'vv';
        }
        if ( $delta < 0 ) {
            return 'v';
        }
        return '=';
    }

    /**
     * Composite threat score 0-100 with transparent, capped components.
     * Each component contributes at most 25 points so no single signal can
     * dominate. Returns the score, label, and the per-component breakdown
     * so the digest can show "why".
     */
    public static function compute_threat_score( array $snapshot ) {
        $components = array();

        $components['failed_login_volume'] = array(
            'value'  => (int) $snapshot['logins_fail'],
            'weight' => 25,
            'cap_at' => 50,
            'label'  => __( 'Failed-login volume', 'brainwerk-security-suite' ),
        );
        $components['distinct_attackers'] = array(
            'value'  => (int) $snapshot['distinct_ips'],
            'weight' => 25,
            'cap_at' => 20,
            'label'  => __( 'Distinct attacker IPs', 'brainwerk-security-suite' ),
        );
        $components['probing_density'] = array(
            'value'  => (int) $snapshot['probing_404s'],
            'weight' => 25,
            'cap_at' => 100,
            'label'  => __( 'Probing 404 density', 'brainwerk-security-suite' ),
        );
        // Escalation signals: lockouts/anomalies/blocks PLUS scanner findings.
        // A single malware_suspect or core-file change is a much stronger signal
        // than a brute-force lockout, so they get heavier per-event weights.
        $escalation = (int) $snapshot['lockouts'] * 5
            + (int) $snapshot['anomalies'] * 3
            + (int) $snapshot['logins_blocked'] * 2
            + (int) ( $snapshot['malware_suspect'] ?? 0 ) * 8
            + (int) ( $snapshot['file_changed'] ?? 0 ) * 4
            + (int) ( $snapshot['file_missing'] ?? 0 ) * 2;

        $components['escalation_signals'] = array(
            'value'  => $escalation,
            'weight' => 25,
            'cap_at' => 25,
            'label'  => __( 'Escalation signals (lockouts, anomalies, blocks, file changes, malware suspects)', 'brainwerk-security-suite' ),
        );

        $total  = 0.0;
        $break  = array();
        foreach ( $components as $key => $c ) {
            $points = ( min( $c['value'], $c['cap_at'] ) / max( 1, $c['cap_at'] ) ) * $c['weight'];
            $total += $points;
            $break[ $key ] = array(
                'label'  => $c['label'],
                'value'  => $c['value'],
                'cap_at' => $c['cap_at'],
                'points' => round( $points, 1 ),
                'weight' => $c['weight'],
            );
        }

        $score = (int) round( min( 100.0, $total ) );
        return array(
            'score'      => $score,
            'label'      => self::label_for_score( $score ),
            'components' => $break,
        );
    }

    public static function label_for_score( $score ) {
        if ( $score >= 70 ) {
            return self::LABEL_CRITICAL;
        }
        if ( $score >= 40 ) {
            return self::LABEL_HIGH;
        }
        if ( $score >= 20 ) {
            return self::LABEL_ELEVATED;
        }
        return self::LABEL_CALM;
    }

    /**
     * Top usernames hit by failed login attempts. Reveals which accounts
     * attackers are guessing (admin / administrator / WordPress / root etc).
     */
    public static function top_targeted_usernames( $since_mysql, $limit = 5 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        return $wpdb->get_results( $wpdb->prepare(
            "SELECT username, COUNT(*) AS hits, COUNT(DISTINCT ip) AS sources
             FROM {$table}
             WHERE created_at >= %s
               AND event_type = %s
               AND username <> ''
             GROUP BY username
             ORDER BY hits DESC
             LIMIT %d",
            $since_mysql, Shieldforge_Logger::EVT_LOGIN_FAIL, (int) $limit
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }

    /**
     * Top probing URI patterns from EVT_PROBING_404 events.
     * Trims request_uri to a recognizable head so noisy query strings
     * don't fragment the grouping.
     */
    public static function top_probing_patterns( $since_mysql, $limit = 5 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        return $wpdb->get_results( $wpdb->prepare(
            "SELECT
                SUBSTRING_INDEX(SUBSTRING_INDEX(request_uri, '?', 1), '#', 1) AS uri,
                COUNT(*) AS hits,
                COUNT(DISTINCT ip) AS sources
             FROM {$table}
             WHERE created_at >= %s
               AND event_type = %s
               AND request_uri IS NOT NULL
               AND request_uri <> ''
             GROUP BY uri
             ORDER BY hits DESC
             LIMIT %d",
            $since_mysql, Shieldforge_Logger::EVT_PROBING_404, (int) $limit
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }

    /**
     * Local "Meistangegriffene Plugins/Themes" breakdown: scans a capped
     * window of raw rows and classifies each request_uri with
     * Shieldforge_Sensor_Privacy::classify_target_slug(). Pure local
     * aggregation, no network involved.
     *
     * @return array<int,array{slug:string,hits:int}>
     */
    public static function top_attacked_slugs( $since_mysql, $limit = 8 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT request_uri FROM {$table}
             WHERE created_at >= %s AND severity >= %d
               AND request_uri IS NOT NULL AND request_uri <> ''
             ORDER BY id DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN, 5000
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( ! class_exists( 'Shieldforge_Sensor_Privacy' ) ) {
            return array();
        }

        $counts = array();
        foreach ( $rows as $r ) {
            $slug = Shieldforge_Sensor_Privacy::classify_target_slug( (string) $r->request_uri );
            if ( null === $slug ) {
                continue;
            }
            $counts[ $slug ] = ( isset( $counts[ $slug ] ) ? $counts[ $slug ] : 0 ) + 1;
        }
        arsort( $counts );
        $counts = array_slice( $counts, 0, max( 1, (int) $limit ), true );

        $out = array();
        foreach ( $counts as $slug => $hits ) {
            $out[] = array( 'slug' => $slug, 'hits' => $hits );
        }
        return $out;
    }

    /**
     * Local "Angriffsklassen" breakdown: scans a capped window of raw rows
     * and classifies each row with Shieldforge_Sensor_Privacy::classify_attack_class(),
     * using the row's context (rule id) and request_uri. Pure local
     * aggregation, no network involved.
     *
     * @return array<int,array{class:string,hits:int}>
     */
    public static function top_attack_classes( $since_mysql, $limit = 9 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT request_uri, context FROM {$table}
             WHERE created_at >= %s AND severity >= %d
             ORDER BY id DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN, 5000
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( ! class_exists( 'Shieldforge_Sensor_Privacy' ) ) {
            return array();
        }

        $counts = array();
        foreach ( $rows as $r ) {
            $ctx     = json_decode( (string) $r->context, true );
            $ctx     = is_array( $ctx ) ? $ctx : array();
            $rule_id = isset( $ctx['rule_id'] ) ? (string) $ctx['rule_id'] : ( isset( $ctx['rule'] ) ? (string) $ctx['rule'] : '' );
            $class   = Shieldforge_Sensor_Privacy::classify_attack_class( $rule_id, (string) $r->request_uri, $ctx );
            if ( null === $class ) {
                continue;
            }
            $counts[ $class ] = ( isset( $counts[ $class ] ) ? $counts[ $class ] : 0 ) + 1;
        }
        arsort( $counts );
        $counts = array_slice( $counts, 0, max( 1, (int) $limit ), true );

        $out = array();
        foreach ( $counts as $class => $hits ) {
            $out[] = array( 'class' => $class, 'hits' => $hits );
        }
        return $out;
    }

    /**
     * Local "Angreifer-Werkzeuge" breakdown: scans a capped window of raw
     * rows and classifies each row's user_agent with
     * Shieldforge_Sensor_Privacy::classify_tool_class(). Pure local
     * aggregation, no network involved.
     *
     * @return array<int,array{tool:string,hits:int}>
     */
    public static function top_attacker_tools( $since_mysql, $limit = 11 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT user_agent FROM {$table}
             WHERE created_at >= %s AND severity >= %d
             ORDER BY id DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN, 5000
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( ! class_exists( 'Shieldforge_Sensor_Privacy' ) ) {
            return array();
        }

        $counts = array();
        foreach ( $rows as $r ) {
            $tool = Shieldforge_Sensor_Privacy::classify_tool_class( (string) $r->user_agent );
            if ( null === $tool ) {
                continue;
            }
            $counts[ $tool ] = ( isset( $counts[ $tool ] ) ? $counts[ $tool ] : 0 ) + 1;
        }
        arsort( $counts );
        $counts = array_slice( $counts, 0, max( 1, (int) $limit ), true );

        $out = array();
        foreach ( $counts as $tool => $hits ) {
            $out[] = array( 'tool' => $tool, 'hits' => $hits );
        }
        return $out;
    }

    /**
     * Coarse attack-type breakdown for the "Angriffsarten" bars. Honeypot
     * hits are logged under EVT_HARDENING_BLOCK with a distinguishing context
     * reason, so they're split out of the hardening-block total here; the
     * remainder is treated as generic WAF/hardening blocks.
     *
     * @return array{bruteforce:int,honeypot:int,probing:int,waf:int,malware:int,anomaly:int}
     */
    public static function attack_type_breakdown( $since_mysql ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );

        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $honeypot = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND event_type = %s AND context LIKE %s",
            $since_mysql, Shieldforge_Logger::EVT_HARDENING_BLOCK, '%honeypot_triggered%'
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $hardening_total = Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_HARDENING_BLOCK ) );
        $brute = Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_LOGIN_FAIL ) )
            + Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_BF_LOCKOUT ) );

        return array(
            'bruteforce' => $brute,
            'honeypot'   => $honeypot,
            'probing'    => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_PROBING_404 ) ),
            'waf'        => max( 0, $hardening_total - $honeypot ),
            'malware'    => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_MALWARE_SUSPECT ) ),
            'anomaly'    => Shieldforge_Logger::count_since( $since_mysql, array( 'event_type' => Shieldforge_Logger::EVT_ANOMALY ) ),
        );
    }

    /**
     * Render the "Angriffs-Aktivität" 24h hourly line/area chart as inline
     * SVG. $hourly is the 24-int array from hourly_histogram().
     */
    public static function render_hourly_svg( array $hourly, $width = 680, $height = 160 ) {
        $n      = count( $hourly );
        if ( 0 === $n ) {
            return '';
        }
        $pad_l  = 4;
        $pad_r  = 4;
        $pad_t  = 10;
        $pad_b  = 18;
        $w      = $width - $pad_l - $pad_r;
        $h      = $height - $pad_t - $pad_b;
        $max    = max( 1, max( $hourly ) );
        $step   = $w / max( 1, $n - 1 );

        $points = array();
        foreach ( array_values( $hourly ) as $i => $v ) {
            $x = $pad_l + $i * $step;
            $y = $pad_t + $h - ( $v / $max ) * $h;
            $points[] = array( $x, $y );
        }

        $line_d = '';
        foreach ( $points as $i => $p ) {
            $line_d .= ( 0 === $i ? 'M' : 'L' ) . sprintf( '%.1f,%.1f ', $p[0], $p[1] );
        }
        $area_d = 'M' . sprintf( '%.1f,%.1f ', $pad_l, $pad_t + $h ) . 'L';
        foreach ( $points as $p ) {
            $area_d .= sprintf( '%.1f,%.1f ', $p[0], $p[1] );
        }
        $area_d .= sprintf( '%.1f,%.1f Z', $pad_l + $w, $pad_t + $h );

        $grid = '';
        for ( $g = 0; $g <= 3; $g++ ) {
            $gy = $pad_t + ( $h / 3 ) * $g;
            $grid .= sprintf( '<line class="grid-line" x1="%1$.1f" y1="%2$.1f" x2="%3$.1f" y2="%2$.1f"/>', $pad_l, $gy, $pad_l + $w );
        }

        $labels = '';
        foreach ( array( 0, 6, 12, 18, 23 ) as $hr ) {
            if ( ! isset( $points[ $hr ] ) ) {
                continue;
            }
            $labels .= sprintf(
                '<text x="%1$.1f" y="%2$d" text-anchor="middle">%3$02d:00</text>',
                $points[ $hr ][0], $height - 3, $hr
            );
        }

        return sprintf(
            '<svg viewBox="0 0 %1$d %2$d" class="sf-ic-timeline-svg" role="img" aria-label="%3$s">%4$s<path class="area" d="%5$s"/><path class="line" d="%6$s"/>%7$s</svg>',
            $width, $height,
            esc_attr__( '24-Stunden-Angriffsaktivität', 'brainwerk-security-suite' ),
            $grid, esc_attr( $area_d ), esc_attr( $line_d ), $labels
        );
    }

    /**
     * Top attacker countries (24h/etc) by resolving each attacking IP's
     * country locally via Shieldforge_Geo — no external lookups. Caps the
     * number of distinct IPs scanned so a busy site stays cheap.
     *
     * @return array<int,array{cc:string,name:string,flag:string,hits:int}>
     */
    public static function top_attacker_countries( $since_mysql, $limit = 8 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT ip, COUNT(*) AS hits FROM {$table}
             WHERE created_at >= %s AND severity >= %d AND ip <> ''
             GROUP BY ip ORDER BY hits DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN, 3000
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        $by_cc = array();
        foreach ( $rows as $r ) {
            $cc = class_exists( 'Shieldforge_Geo' ) ? Shieldforge_Geo::country_for_ip( $r->ip ) : Shieldforge_Geo::UNKNOWN;
            if ( Shieldforge_Geo::UNKNOWN === $cc ) {
                continue;
            }
            $by_cc[ $cc ] = ( isset( $by_cc[ $cc ] ) ? $by_cc[ $cc ] : 0 ) + (int) $r->hits;
        }
        arsort( $by_cc );
        $by_cc = array_slice( $by_cc, 0, max( 1, (int) $limit ), true );

        $out = array();
        foreach ( $by_cc as $cc => $hits ) {
            $out[] = array(
                'cc'   => $cc,
                'name' => Shieldforge_Geo::country_name( $cc ),
                'flag' => Shieldforge_Geo::flag_emoji( $cc ),
                'hits' => $hits,
            );
        }
        return $out;
    }

    /**
     * Top attacker networks (ASN/provider) by resolving each attacking IP's
     * ASN locally via Shieldforge_Asn — no external lookups. Caps the number
     * of distinct IPs scanned so a busy site stays cheap.
     *
     * @return array<int,array{asn:int,org:?string,count:int}>
     */
    public static function top_attacker_asns( $since_mysql, $limit = 8 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT ip, COUNT(*) AS hits FROM {$table}
             WHERE created_at >= %s AND severity >= %d AND ip <> ''
             GROUP BY ip ORDER BY hits DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN, 3000
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( ! class_exists( 'Shieldforge_Asn' ) ) {
            return array();
        }

        $by_asn = array();
        $orgs   = array();
        foreach ( $rows as $r ) {
            $info = Shieldforge_Asn::lookup( $r->ip );
            if ( null === $info ) {
                continue;
            }
            $asn            = $info['asn'];
            $by_asn[ $asn ] = ( isset( $by_asn[ $asn ] ) ? $by_asn[ $asn ] : 0 ) + (int) $r->hits;
            if ( ! isset( $orgs[ $asn ] ) ) {
                $orgs[ $asn ] = $info['org'];
            }
        }
        arsort( $by_asn );
        $by_asn = array_slice( $by_asn, 0, max( 1, (int) $limit ), true );

        $out = array();
        foreach ( $by_asn as $asn => $count ) {
            $out[] = array(
                'asn'   => (int) $asn,
                'org'   => isset( $orgs[ $asn ] ) ? $orgs[ $asn ] : null,
                'count' => $count,
            );
        }
        return $out;
    }

    /**
     * 24-bucket histogram of WARN+ events keyed by hour-of-day (UTC) for an
     * explicit since-window. Parameterised sibling of hourly_histogram() so
     * callers that already have a since-timestamp (e.g. the command center)
     * don't need to re-derive it.
     *
     * @return array<int,int> indexed 0..23
     */
    public static function hour_of_day_local( $since_mysql ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );

        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT HOUR(created_at) AS h, COUNT(*) AS n
             FROM {$table}
             WHERE created_at >= %s AND severity >= %d
             GROUP BY HOUR(created_at)",
            $since_mysql, Shieldforge_Logger::SEV_WARN
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        $hist = array_fill( 0, 24, 0 );
        foreach ( $rows as $r ) {
            $h = (int) $r->h;
            if ( $h >= 0 && $h < 24 ) {
                $hist[ $h ] = (int) $r->n;
            }
        }
        return $hist;
    }

    /**
     * Attack velocity: WARN+ events in the last hour vs. the previous hour,
     * with a coarse trend classification ('up'/'down'/'flat', >=20% delta).
     *
     * @return array{last_hour:int,prev_hour:int,trend:string}
     */
    public static function velocity_local() {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        $now   = time();

        $last_since = gmdate( 'Y-m-d H:i:s', $now - HOUR_IN_SECONDS );
        $prev_since = gmdate( 'Y-m-d H:i:s', $now - 2 * HOUR_IN_SECONDS );

        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $last_hour = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND severity >= %d",
            $last_since, Shieldforge_Logger::SEV_WARN
        ) );
        $prev_hour = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND created_at < %s AND severity >= %d",
            $prev_since, $last_since, Shieldforge_Logger::SEV_WARN
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        return array(
            'last_hour' => $last_hour,
            'prev_hour' => $prev_hour,
            'trend'     => self::velocity_trend( $last_hour, $prev_hour ),
        );
    }

    /** Classify a last-hour/prev-hour pair into 'up'/'down'/'flat' (>=20% delta). */
    public static function velocity_trend( $last, $prev ) {
        $last = (int) $last;
        $prev = (int) $prev;
        if ( $prev <= 0 ) {
            return $last > 0 ? 'up' : 'flat';
        }
        $delta_pct = ( ( $last - $prev ) / $prev ) * 100.0;
        if ( $delta_pct >= 20 ) {
            return 'up';
        }
        if ( $delta_pct <= -20 ) {
            return 'down';
        }
        return 'flat';
    }

    /**
     * Local threat-map corridors: attacker country -> this site's country,
     * with the dominant CVE (if any) and endpoint class per corridor. Scans a
     * capped window of raw rows (not aggregated in SQL) so the per-row CVE /
     * endpoint-class classification can run in PHP without a heavy query.
     *
     * @return array<int,array{attacker:string,victim:string,events:int,top_cve:?string,top_endpoint:?string,exploited:bool}>
     */
    public static function corridors_local( $since_mysql, $limit = 12 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT ip, request_uri FROM {$table}
             WHERE created_at >= %s AND severity >= %d AND ip <> ''
             ORDER BY id DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::SEV_WARN, 5000
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        $victim = class_exists( 'Shieldforge_Geo' ) ? Shieldforge_Geo::site_country() : Shieldforge_Geo::UNKNOWN;

        $hits      = array();
        $cve_count = array();
        $ep_count  = array();
        foreach ( $rows as $r ) {
            $cc = Shieldforge_Geo::country_for_ip( $r->ip );
            if ( Shieldforge_Geo::UNKNOWN === $cc ) {
                continue;
            }
            $hits[ $cc ] = ( isset( $hits[ $cc ] ) ? $hits[ $cc ] : 0 ) + 1;

            $uri = (string) $r->request_uri;
            if ( '' !== $uri && class_exists( 'Shieldforge_Cve_Rules' ) ) {
                $cve = Shieldforge_Cve_Rules::match( $uri );
                if ( $cve ) {
                    $cve_count[ $cc ][ $cve ] = ( isset( $cve_count[ $cc ][ $cve ] ) ? $cve_count[ $cc ][ $cve ] : 0 ) + 1;
                }
            }
            if ( class_exists( 'Shieldforge_Sensor_Privacy' ) ) {
                $ep = Shieldforge_Sensor_Privacy::classify_endpoint( $uri );
                $ep_count[ $cc ][ $ep ] = ( isset( $ep_count[ $cc ][ $ep ] ) ? $ep_count[ $cc ][ $ep ] : 0 ) + 1;
            }
        }
        arsort( $hits );
        $hits = array_slice( $hits, 0, max( 1, (int) $limit ), true );

        $out = array();
        foreach ( $hits as $cc => $n ) {
            $top_cve = self::argmax( isset( $cve_count[ $cc ] ) ? $cve_count[ $cc ] : array() );
            $top_ep  = self::argmax( isset( $ep_count[ $cc ] ) ? $ep_count[ $cc ] : array() );
            $out[]   = array(
                'attacker'     => $cc,
                'victim'       => $victim,
                'events'       => $n,
                'top_cve'      => $top_cve,
                'top_endpoint' => $top_ep,
                'exploited'    => null !== $top_cve,
            );
        }
        return $out;
    }

    /** Key with the highest value in an assoc array, or null if empty. */
    private static function argmax( array $counts ) {
        if ( empty( $counts ) ) {
            return null;
        }
        arsort( $counts );
        return (string) array_key_first( $counts );
    }

    /**
     * Render the "Angriffs-Korridore" bipartite arc/chord SVG: attacker
     * countries on the left axis, victim countries on the right, connecting
     * bands weighted by event volume. Sequential blue ramp for volume;
     * critical red for any corridor flagged as carrying an actively exploited
     * CVE. Pure inline SVG — no external assets.
     *
     * @param array $corridors list of ['attacker','victim','events','exploited', ...]
     */
    public static function render_corridor_svg( array $corridors, $width = 760, $height = 380 ) {
        if ( empty( $corridors ) ) {
            return '';
        }

        $left_totals  = array();
        $right_totals = array();
        foreach ( $corridors as $c ) {
            $a = (string) $c['attacker'];
            $v = (string) $c['victim'];
            $n = (int) $c['events'];
            $left_totals[ $a ]  = ( isset( $left_totals[ $a ] ) ? $left_totals[ $a ] : 0 ) + $n;
            $right_totals[ $v ] = ( isset( $right_totals[ $v ] ) ? $right_totals[ $v ] : 0 ) + $n;
        }
        arsort( $left_totals );
        arsort( $right_totals );

        $grand_total = max( 1, array_sum( $left_totals ) );
        $pad_top     = 28;
        $pad_bottom  = 20;
        $usable_h    = max( 40, $height - $pad_top - $pad_bottom );
        $gap         = 6;
        $node_w      = 10;
        $left_x      = 90;
        $right_x     = $width - 90 - $node_w;

        $left_y = array();
        $y      = $pad_top;
        $n_left = max( 1, count( $left_totals ) );
        foreach ( $left_totals as $cc => $tot ) {
            $h = max( 6, ( $tot / $grand_total ) * ( $usable_h - $gap * ( $n_left - 1 ) ) );
            $left_y[ $cc ] = array( 'y' => $y, 'h' => $h, 'cursor' => $y );
            $y += $h + $gap;
        }
        $right_y = array();
        $y       = $pad_top;
        $n_right = max( 1, count( $right_totals ) );
        foreach ( $right_totals as $cc => $tot ) {
            $h = max( 6, ( $tot / $grand_total ) * ( $usable_h - $gap * ( $n_right - 1 ) ) );
            $right_y[ $cc ] = array( 'y' => $y, 'h' => $h, 'cursor' => $y );
            $y += $h + $gap;
        }

        $max_event = max( 1, max( array_column( $corridors, 'events' ) ) );
        // Sequential blue ramp: light -> --c-blue for increasing relative volume.
        $ramp = array( '#cfe0f5', '#9cc3e8', '#5f97d6', '#2a78d6', '#1a5aa8' );

        usort( $corridors, function ( $a, $b ) { return (int) $b['events'] - (int) $a['events']; } );

        $bands = '';
        foreach ( $corridors as $c ) {
            $a = (string) $c['attacker'];
            $v = (string) $c['victim'];
            $n = (int) $c['events'];
            if ( ! isset( $left_y[ $a ] ) || ! isset( $right_y[ $v ] ) ) {
                continue;
            }
            $thickness = max( 2, ( $n / $grand_total ) * ( $usable_h - $gap * ( $n_left - 1 ) ) );

            $ly = $left_y[ $a ]['cursor'];
            $ry = $right_y[ $v ]['cursor'];
            $left_y[ $a ]['cursor']  += $thickness;
            $right_y[ $v ]['cursor'] += $thickness;

            $x1 = $left_x + $node_w;
            $x2 = $right_x;
            $y1 = $ly + $thickness / 2;
            $y2 = $ry + $thickness / 2;
            $mx = ( $x1 + $x2 ) / 2;

            $rel   = $n / $max_event;
            $idx   = (int) min( count( $ramp ) - 1, floor( $rel * ( count( $ramp ) - 1 ) ) );
            $color = ! empty( $c['exploited'] ) ? 'var(--st-critical)' : $ramp[ $idx ];

            $title = sprintf(
                '%s -> %s: %d Ereignis(se)%s',
                Shieldforge_Geo::country_name( $a ),
                Shieldforge_Geo::country_name( $v ),
                $n,
                ! empty( $c['top_cve'] ) ? ' (' . $c['top_cve'] . ')' : ''
            );

            $bands .= sprintf(
                '<path d="M%1$.1f,%2$.1f C%3$.1f,%2$.1f %3$.1f,%4$.1f %5$.1f,%4$.1f" fill="none" stroke="%6$s" stroke-width="%7$.1f" stroke-opacity="0.75"><title>%8$s</title></path>',
                $x1, $y1, $mx, $y2, $x2, esc_attr( $color ), max( 1.5, $thickness ), esc_html( $title )
            );
        }

        $nodes = '';
        foreach ( $left_y as $cc => $pos ) {
            $nodes .= sprintf(
                '<rect x="%1$d" y="%2$.1f" width="%3$d" height="%4$.1f" rx="2" fill="var(--c-blue)"/>',
                $left_x, $pos['y'], $node_w, $pos['h']
            );
            $nodes .= sprintf(
                '<text x="%1$d" y="%2$.1f" text-anchor="end" class="sf-chord-label">%3$s %4$s</text>',
                $left_x - 8, $pos['y'] + $pos['h'] / 2 + 4,
                esc_html( Shieldforge_Geo::flag_emoji( $cc ) ), esc_html( Shieldforge_Geo::country_name( $cc ) )
            );
        }
        foreach ( $right_y as $cc => $pos ) {
            $nodes .= sprintf(
                '<rect x="%1$d" y="%2$.1f" width="%3$d" height="%4$.1f" rx="2" fill="var(--c-violet)"/>',
                $right_x, $pos['y'], $node_w, $pos['h']
            );
            $nodes .= sprintf(
                '<text x="%1$d" y="%2$.1f" text-anchor="start" class="sf-chord-label">%3$s %4$s</text>',
                $right_x + $node_w + 8, $pos['y'] + $pos['h'] / 2 + 4,
                esc_html( Shieldforge_Geo::flag_emoji( $cc ) ), esc_html( Shieldforge_Geo::country_name( $cc ) )
            );
        }

        return sprintf(
            '<svg viewBox="0 0 %1$d %2$d" class="sf-chord-svg" role="img" aria-label="%3$s">%4$s%5$s</svg>',
            $width, $height,
            esc_attr__( 'Angriffs-Korridore: Herkunfts- zu Ziel-Ländern', 'brainwerk-security-suite' ),
            $bands, $nodes
        );
    }

    /**
     * 24-bucket histogram of WARN+ events keyed by hour-of-day (UTC).
     * Returns an array indexed 0..23 with int counts.
     */
    public static function hourly_histogram( $now ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        $since = gmdate( 'Y-m-d H:i:s', (int) $now - DAY_IN_SECONDS );

        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT HOUR(created_at) AS h, COUNT(*) AS n
             FROM {$table}
             WHERE created_at >= %s AND severity >= %d
             GROUP BY HOUR(created_at)",
            $since, Shieldforge_Logger::SEV_WARN
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        $hist = array_fill( 0, 24, 0 );
        foreach ( $rows as $r ) {
            $h = (int) $r->h;
            if ( $h >= 0 && $h < 24 ) {
                $hist[ $h ] = (int) $r->n;
            }
        }
        return $hist;
    }

    /**
     * Render a pure-ASCII sparkline. Pure-ASCII (no Unicode) so it survives
     * every mail pipeline -classic SMTP, OAuth-SMTP plugins (WP Mail SMTP,
     * FluentSMTP, Post SMTP, Office365), and php mail() identically.
     */
    public static function format_sparkline( array $values ) {
        if ( empty( $values ) ) {
            return '';
        }
        $palette = array( ' ', '.', ':', '-', '=', '+', '*', '#', '@' );
        $max     = max( $values );
        if ( $max <= 0 ) {
            return str_repeat( '.', count( $values ) );
        }
        $out = '';
        foreach ( $values as $v ) {
            $idx = (int) round( ( $v / $max ) * ( count( $palette ) - 1 ) );
            $out .= $palette[ $idx ];
        }
        return $out;
    }

    public static function recent_anomalies( $since_mysql, $limit = 3 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        // 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; $table is a code-constructed, trusted table name; all user-supplied values use $wpdb->prepare() placeholders.
        $rows  = $wpdb->get_results( $wpdb->prepare(
            "SELECT created_at, username, ip, context
             FROM {$table}
             WHERE created_at >= %s AND event_type = %s
             ORDER BY id DESC LIMIT %d",
            $since_mysql, Shieldforge_Logger::EVT_ANOMALY, (int) $limit
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        foreach ( $rows as $r ) {
            $decoded = json_decode( (string) $r->context, true );
            $r->ctx  = is_array( $decoded ) ? $decoded : array();
        }
        return $rows;
    }

    /* ------------------------------------------------------------------
     * Plain-text rendering (mail body)
     * ------------------------------------------------------------------ */

    public static function format_digest_text( array $data, $site_name = '' ) {
        $now      = (int) $data['now'];
        $snapshot = $data['snapshot_24h'];
        $trend    = $data['trend'];
        $score    = $data['score'];

        $lines = array();
        $lines[] = '=======================================================';
        $lines[] = sprintf( 'Brainwerk Security Suite Daily Digest - %s', gmdate( 'Y-m-d H:i', $now ) . ' UTC' );
        if ( $site_name ) {
            $lines[] = sprintf( 'Site: %s', $site_name );
        }
        $lines[] = '=======================================================';
        $lines[] = '';

        // --- THREAT SCORE -------------------------------------------------
        $score_bar = self::format_score_bar( $score['score'] );
        $lines[] = sprintf( 'THREAT LEVEL: %s  (%d / 100)', strtoupper( $score['label'] ), $score['score'] );
        $lines[] = '  ' . $score_bar;
        $lines[] = '';
        $lines[] = 'Score breakdown (each capped at 25 points):';
        foreach ( $score['components'] as $c ) {
            $lines[] = sprintf(
                '  %-44s %5.1f pts   (raw %d, cap %d)',
                self::truncate( $c['label'], 44 ),
                $c['points'],
                $c['value'],
                $c['cap_at']
            );
        }
        $lines[] = '';

        // --- 24H SNAPSHOT vs 7D AVG ---------------------------------------
        $lines[] = '--- 24h activity vs. 7-day average ---';
        $lines[] = sprintf(
            '  %-26s %8s %12s %8s %6s',
            'Metric', '24h', '7d-avg/day', 'Diff%', 'Trend'
        );
        $rows = array(
            'logins_ok'        => 'Successful logins',
            'logins_fail'      => 'Failed logins',
            'logins_blocked'   => 'Logins blocked (BF lockout)',
            'lockouts'         => 'New brute-force lockouts',
            'probing_404s'     => 'Probing 404 hits',
            'hardening_blocks' => 'Hardening blocks',
            'anomalies'        => 'Anomalous logins',
            'distinct_ips'     => 'Distinct attacker IPs',
            'file_added'       => 'Files added (scanner)',
            'file_changed'     => 'Files changed (scanner)',
            'file_missing'     => 'Files missing (scanner)',
            'malware_suspect'  => 'Suspicious-pattern hits',
        );
        foreach ( $rows as $key => $label ) {
            $t = isset( $trend[ $key ] ) ? $trend[ $key ] : null;
            if ( ! $t ) {
                continue;
            }
            $sign = $t['delta'] > 0 ? '+' : ( $t['delta'] < 0 ? '' : ' ' );
            $lines[] = sprintf(
                '  %-26s %8d %12s %s%6.0f%% %6s',
                self::truncate( $label, 26 ),
                $t['current'],
                number_format( $t['baseline'], 1 ),
                $sign,
                $t['pct'],
                $t['arrow']
            );
        }
        $lines[] = '';

        // --- HOURLY HEATMAP ----------------------------------------------
        $lines[] = '--- 24h activity heatmap (WARN+, UTC) ---';
        $lines[] = '  hour: 0    4    8   12   16   20    23';
        $lines[] = '         ' . self::format_sparkline( $data['hourly'] );
        $peak_hour  = self::peak_hour( $data['hourly'] );
        if ( null !== $peak_hour ) {
            $lines[] = sprintf(
                '  peak hour: %02d:00 UTC (%d events)',
                $peak_hour,
                $data['hourly'][ $peak_hour ]
            );
        }
        $lines[] = '';

        // --- TOP ATTACKERS ------------------------------------------------
        if ( ! empty( $data['top_attackers'] ) ) {
            $lines[] = '--- Top attacker IPs (24h, WARN+) ---';
            foreach ( $data['top_attackers'] as $row ) {
                $lines[] = sprintf( '  %-45s %5d hits', $row->ip, (int) $row->hits );
            }
            $lines[] = '';
        }

        // --- TOP TARGETED USERNAMES ---------------------------------------
        if ( ! empty( $data['top_usernames'] ) ) {
            $lines[] = '--- Top targeted usernames (24h failed logins) ---';
            $lines[] = sprintf( '  %-30s %8s %10s', 'Username', 'attempts', 'sources' );
            foreach ( $data['top_usernames'] as $row ) {
                $lines[] = sprintf(
                    '  %-30s %8d %10d',
                    self::truncate( (string) $row->username, 30 ),
                    (int) $row->hits,
                    (int) $row->sources
                );
            }
            $lines[] = '';
        }

        // --- TOP PROBING PATTERNS -----------------------------------------
        if ( ! empty( $data['top_probing'] ) ) {
            $lines[] = '--- Top probing URI patterns (24h) ---';
            $lines[] = sprintf( '  %-46s %8s %10s', 'URI', 'hits', 'sources' );
            foreach ( $data['top_probing'] as $row ) {
                $lines[] = sprintf(
                    '  %-46s %8d %10d',
                    self::truncate( (string) $row->uri, 46 ),
                    (int) $row->hits,
                    (int) $row->sources
                );
            }
            $lines[] = '';
        }

        // --- ANOMALY HIGHLIGHTS -------------------------------------------
        if ( ! empty( $data['recent_anomalies'] ) ) {
            $lines[] = '--- Anomaly highlights (24h) ---';
            foreach ( $data['recent_anomalies'] as $a ) {
                $ctx        = isset( $a->ctx ) ? $a->ctx : array();
                $top_score  = isset( $ctx['top_score'] ) ? (int) $ctx['top_score'] : 0;
                $top_metric = isset( $ctx['top_metric'] ) ? (string) $ctx['top_metric'] : '';
                $z          = isset( $ctx['z'] ) ? (float) $ctx['z'] : 0.0;
                $lines[] = sprintf(
                    '  [%s] %s from %s - score %d (%s, z=%.2f)',
                    $a->created_at,
                    $a->username ?: '?',
                    $a->ip ?: '?',
                    $top_score,
                    $top_metric,
                    $z
                );
            }
            $lines[] = '';
        }

        // --- VULNERABLE COMPONENTS -----------------------------------
        if ( class_exists( 'Shieldforge_VulnScanner' ) ) {
            $vc_active = Shieldforge_VulnScanner::active_critical_count();
            $cached    = Shieldforge_VulnScanner::get_cached_results();
            if ( $vc_active > 0 || ( is_array( $cached ) && ( ! empty( $cached['plugins'] ) || ! empty( $cached['themes'] ) || ! empty( $cached['core'] ) ) ) ) {
                $lines[] = '--- Vulnerable components ---';
                $lines[] = sprintf(
                    '  active critical: %d   plugins flagged: %d   themes: %d   core: %d',
                    (int) $vc_active,
                    is_array( $cached ) ? (int) count( $cached['plugins'] ?? array() ) : 0,
                    is_array( $cached ) ? (int) count( $cached['themes'] ?? array() ) : 0,
                    is_array( $cached ) ? (int) count( $cached['core'] ?? array() ) : 0
                );
                if ( is_array( $cached ) ) {
                    foreach ( array( 'plugins', 'themes', 'core' ) as $bucket ) {
                        foreach ( (array) ( $cached[ $bucket ] ?? array() ) as $entry ) {
                            if ( empty( $entry['active'] ) ) {
                                continue;
                            }
                            $lines[] = sprintf(
                                '  [%s] %s %s — %d vuln(s)%s',
                                substr( $bucket, 0, -1 ), // 'plugin' / 'theme' / 'core'
                                self::truncate( (string) $entry['name'], 30 ),
                                (string) $entry['version'],
                                (int) count( (array) ( $entry['vulns'] ?? array() ) ),
                                ! empty( $entry['vulns'][0]['fixed_in'] ) ? ' fix in ' . (string) $entry['vulns'][0]['fixed_in'] : ''
                            );
                        }
                    }
                }
                $lines[] = '  Open Vulnerabilities tab: ' . esc_url_raw( admin_url( 'admin.php?page=brainwerk-security-suite&tab=vulnerabilities' ) );
                $lines[] = '';
            }
        }

        // --- FILE-INTEGRITY HIGHLIGHTS -----------------------------------
        $fi_total = (int) ( $snapshot['file_added'] ?? 0 )
            + (int) ( $snapshot['file_changed'] ?? 0 )
            + (int) ( $snapshot['file_missing'] ?? 0 )
            + (int) ( $snapshot['malware_suspect'] ?? 0 );
        if ( $fi_total > 0 ) {
            $lines[] = '--- File-integrity highlights (24h) ---';
            $lines[] = sprintf(
                '  added: %d   changed: %d   missing: %d   suspicious patterns: %d',
                (int) ( $snapshot['file_added'] ?? 0 ),
                (int) ( $snapshot['file_changed'] ?? 0 ),
                (int) ( $snapshot['file_missing'] ?? 0 ),
                (int) ( $snapshot['malware_suspect'] ?? 0 )
            );
            $lines[] = '  Open Scanner tab: ' . esc_url_raw( admin_url( 'admin.php?page=brainwerk-security-suite&tab=scanner' ) );
            $lines[] = '';
        }

        // --- INTERPRETATION HINTS -----------------------------------------
        $lines[] = '--- What this means ---';
        $lines[] = self::interpretation( $score, $snapshot );
        $lines[] = '';
        $lines[] = '=======================================================';
        $lines[] = 'Open the dashboard:';
        $lines[] = '  ' . esc_url_raw( admin_url( 'admin.php?page=brainwerk-security-suite' ) );
        $lines[] = '';
        $lines[] = 'Why am I getting this? You enabled "Daily digest" in';
        $lines[] = 'Brainwerk Security Suite -> Notifications. Disable it there to stop.';
        $lines[] = '=======================================================';

        return implode( "\n", $lines );
    }

    public static function format_score_bar( $score ) {
        $width  = 30;
        $filled = (int) round( ( $score / 100 ) * $width );
        return '[' . str_repeat( '#', $filled ) . str_repeat( '.', $width - $filled ) . ']';
    }

    /* ==================================================================
     *  HTML digest — structured, table-based layout for email clients.
     *  Mirrors format_digest_text() section-for-section. Every dynamic value
     *  is escaped; all styling is inlined (email clients strip <style> blocks
     *  and external CSS). No remote images. Degrades to a readable stack of
     *  tables in clients that ignore widths.
     * ================================================================== */

    const HTML_FONT = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;";

    private static function digest_heading( $title, $sub = '' ) {
        $f = self::HTML_FONT;
        $h = '<div style="border-left:3px solid #2271b1;padding:1px 0 1px 9px;margin:18px 0 9px;">';
        $h .= '<span style="font-size:14px;font-weight:700;color:#1d2327;' . $f . '">' . esc_html( $title ) . '</span>';
        if ( '' !== $sub ) {
            $h .= ' <span style="font-size:11px;color:#646970;' . $f . '">' . esc_html( $sub ) . '</span>';
        }
        return $h . '</div>';
    }

    public static function format_digest_html( array $data, $site_name = '' ) {
        $now      = (int) $data['now'];
        $snapshot = $data['snapshot_24h'];
        $trend    = $data['trend'];
        $score    = $data['score'];

        $muted = '#646970'; $border = '#e2e4e7'; $text = '#1d2327';
        $green = '#2e8b3e'; $amber = '#dba617'; $orange = '#e8772e'; $red = '#d63638';
        $f     = self::HTML_FONT;
        $sv    = (int) $score['score'];
        $tc    = $sv >= 75 ? $red : ( $sv >= 50 ? $orange : ( $sv >= 25 ? $amber : $green ) );

        $td  = "padding:7px 10px;border-bottom:1px solid {$border};font-size:13px;color:{$text};{$f}";
        $tdr = $td . 'text-align:right;';
        $tdm = $td . "color:{$muted};text-align:right;";
        $th  = "padding:7px 10px;background:#f6f7f7;border-bottom:2px solid {$border};font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:{$muted};text-align:left;{$f}";
        $thr = $th . 'text-align:right;';

        $esc = function ( $v ) { return esc_html( (string) $v ); };
        $open_tbl = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:0;">';

        $h  = '<div style="background:#f4f5f7;padding:24px 12px;' . $f . '">';
        $h .= '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;"><tr><td align="center">';
        $h .= '<table role="presentation" width="640" cellpadding="0" cellspacing="0" style="max-width:640px;width:100%;background:#ffffff;border:1px solid ' . $border . ';border-radius:10px;overflow:hidden;">';

        // ---- Header bar -------------------------------------------------
        $h .= '<tr><td style="background:#23282d;padding:18px 22px;">';
        $h .= '<div style="color:#ffffff;font-size:17px;font-weight:700;' . $f . '">&#128737;&#65039; Brainwerk Security Suite</div>';
        $h .= '<div style="color:#c3c4c7;font-size:12px;margin-top:3px;' . $f . '">Daily Security Digest'
            . ( $site_name ? ' &middot; ' . $esc( $site_name ) : '' )
            . ' &middot; ' . $esc( gmdate( 'D, d M Y H:i', $now ) ) . ' UTC</div>';
        $h .= '</td></tr>';

        // ---- Threat hero ------------------------------------------------
        $h .= '<tr><td style="padding:22px 22px 2px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>';
        $h .= '<td style="vertical-align:middle;width:128px;">';
        $h .= '<div style="font-size:42px;line-height:1;font-weight:800;color:' . $tc . ';' . $f . '">' . $sv
            . '<span style="font-size:15px;color:' . $muted . ';font-weight:600;">/100</span></div>';
        $h .= '<div style="display:inline-block;margin-top:7px;padding:3px 11px;border-radius:12px;background:' . $tc . ';color:#fff;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;' . $f . '">'
            . $esc( strtoupper( $score['label'] ) ) . '</div>';
        $h .= '</td><td style="vertical-align:middle;padding-left:16px;">';
        $h .= '<div style="font-size:11px;color:' . $muted . ';text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;' . $f . '">Threat level</div>';
        $barw = max( 0, min( 100, $sv ) );
        $h .= '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eceef0;border-radius:7px;"><tr>'
            . '<td style="width:' . $barw . '%;background:' . $tc . ';height:14px;border-radius:7px;font-size:0;line-height:0;">&nbsp;</td>'
            . '<td style="font-size:0;line-height:0;">&nbsp;</td></tr></table>';
        $h .= '</td></tr></table></td></tr>';

        // ---- Body -------------------------------------------------------
        $h .= '<tr><td style="padding:6px 22px 22px;">';

        // Score breakdown
        $h .= self::digest_heading( 'Score breakdown', 'each component capped at 25 pts' );
        $h .= $open_tbl . '<tr><td style="' . $th . '">Component</td><td style="' . $thr . '">Points</td><td style="' . $thr . '">Raw</td><td style="' . $thr . '">Cap</td></tr>';
        foreach ( $score['components'] as $c ) {
            $h .= '<tr><td style="' . $td . '">' . $esc( $c['label'] ) . '</td>'
                . '<td style="' . $tdr . 'font-weight:600;">' . number_format( (float) $c['points'], 1 ) . '</td>'
                . '<td style="' . $tdm . '">' . (int) $c['value'] . '</td>'
                . '<td style="' . $tdm . '">' . (int) $c['cap_at'] . '</td></tr>';
        }
        $h .= '</table>';

        // 24h vs 7d
        $arrow_map = array(
            '^^' => array( '&#9650;&#9650;', $orange ), '^' => array( '&#9650;', $amber ),
            '='  => array( '&ndash;', $muted ),
            'v'  => array( '&#9660;', $green ), 'vv' => array( '&#9660;&#9660;', $green ),
        );
        $rows = array(
            'logins_ok' => 'Successful logins', 'logins_fail' => 'Failed logins',
            'logins_blocked' => 'Logins blocked (BF lockout)', 'lockouts' => 'New brute-force lockouts',
            'probing_404s' => 'Probing 404 hits', 'hardening_blocks' => 'Hardening blocks',
            'anomalies' => 'Anomalous logins', 'distinct_ips' => 'Distinct attacker IPs',
            'file_added' => 'Files added (scanner)', 'file_changed' => 'Files changed (scanner)',
            'file_missing' => 'Files missing (scanner)', 'malware_suspect' => 'Suspicious-pattern hits',
        );
        $h .= self::digest_heading( '24h activity vs. 7-day average' );
        $h .= $open_tbl . '<tr><td style="' . $th . '">Metric</td><td style="' . $thr . '">24h</td><td style="' . $thr . '">7d&nbsp;avg/day</td><td style="' . $thr . '">Diff</td><td style="' . $thr . '">Trend</td></tr>';
        foreach ( $rows as $key => $label ) {
            $t = isset( $trend[ $key ] ) ? $trend[ $key ] : null;
            if ( ! $t ) { continue; }
            $am   = isset( $arrow_map[ $t['arrow'] ] ) ? $arrow_map[ $t['arrow'] ] : array( '&ndash;', $muted );
            $sign = $t['delta'] > 0 ? '+' : '';
            $h .= '<tr><td style="' . $td . '">' . $esc( $label ) . '</td>'
                . '<td style="' . $tdr . 'font-weight:600;">' . (int) $t['current'] . '</td>'
                . '<td style="' . $tdm . '">' . number_format( (float) $t['baseline'], 1 ) . '</td>'
                . '<td style="' . $tdr . 'color:' . $am[1] . ';">' . $sign . (int) $t['pct'] . '%</td>'
                . '<td style="' . $tdr . 'color:' . $am[1] . ';font-weight:700;">' . $am[0] . '</td></tr>';
        }
        $h .= '</table>';

        // Hourly heatmap
        $hourly = (array) $data['hourly'];
        $maxh   = max( 1, max( $hourly ?: array( 0 ) ) );
        $shades = array( '#eef1f4', '#cfe0ee', '#9cc3e0', '#5a93c4', '#2271b1' );
        $h .= self::digest_heading( '24h activity heatmap', 'WARN+ events per hour, UTC' );
        $h .= '<table role="presentation" cellpadding="0" cellspacing="2" style="border-collapse:separate;"><tr>';
        for ( $hr = 0; $hr < 24; $hr++ ) {
            $v   = (int) ( $hourly[ $hr ] ?? 0 );
            $idx = 0 === $v ? 0 : (int) min( 4, 1 + floor( ( $v / $maxh ) * 3.99 ) );
            $fg  = $idx >= 3 ? '#ffffff' : '#1d2327';
            $h .= '<td title="' . $hr . ':00 — ' . $v . '" style="width:22px;height:24px;background:' . $shades[ $idx ]
                . ';color:' . $fg . ';font-size:10px;text-align:center;border-radius:3px;' . $f . '">' . $v . '</td>';
        }
        $h .= '</tr><tr>';
        for ( $hr = 0; $hr < 24; $hr++ ) {
            $lbl = ( 0 === $hr % 4 ) ? sprintf( '%02d', $hr ) : '';
            $h .= '<td style="font-size:9px;color:' . $muted . ';text-align:center;' . $f . '">' . $lbl . '</td>';
        }
        $h .= '</tr></table>';
        $peak = self::peak_hour( $hourly );
        if ( null !== $peak ) {
            $h .= '<div style="font-size:11px;color:' . $muted . ';margin-top:6px;' . $f . '">Peak hour: <strong>'
                . sprintf( '%02d:00 UTC', $peak ) . '</strong> (' . (int) $hourly[ $peak ] . ' events)</div>';
        }

        // Top attacker IPs
        if ( ! empty( $data['top_attackers'] ) ) {
            $h .= self::digest_heading( 'Top attacker IPs', '24h, WARN+' );
            $h .= $open_tbl . '<tr><td style="' . $th . '">IP address</td><td style="' . $thr . '">Hits</td></tr>';
            foreach ( $data['top_attackers'] as $row ) {
                $h .= '<tr><td style="' . $td . 'font-family:Consolas,Menlo,monospace;">' . $esc( $row->ip ) . '</td>'
                    . '<td style="' . $tdr . 'font-weight:600;">' . (int) $row->hits . '</td></tr>';
            }
            $h .= '</table>';
        }

        // Top targeted usernames
        if ( ! empty( $data['top_usernames'] ) ) {
            $h .= self::digest_heading( 'Top targeted usernames', '24h failed logins' );
            $h .= $open_tbl . '<tr><td style="' . $th . '">Username</td><td style="' . $thr . '">Attempts</td><td style="' . $thr . '">Sources</td></tr>';
            foreach ( $data['top_usernames'] as $row ) {
                $h .= '<tr><td style="' . $td . '">' . $esc( $row->username ) . '</td>'
                    . '<td style="' . $tdr . 'font-weight:600;">' . (int) $row->hits . '</td>'
                    . '<td style="' . $tdm . '">' . (int) $row->sources . '</td></tr>';
            }
            $h .= '</table>';
        }

        // Top probing patterns
        if ( ! empty( $data['top_probing'] ) ) {
            $h .= self::digest_heading( 'Top probing URI patterns', '24h' );
            $h .= $open_tbl . '<tr><td style="' . $th . '">URI</td><td style="' . $thr . '">Hits</td><td style="' . $thr . '">Sources</td></tr>';
            foreach ( $data['top_probing'] as $row ) {
                $h .= '<tr><td style="' . $td . 'font-family:Consolas,Menlo,monospace;word-break:break-all;">' . $esc( $row->uri ) . '</td>'
                    . '<td style="' . $tdr . 'font-weight:600;">' . (int) $row->hits . '</td>'
                    . '<td style="' . $tdm . '">' . (int) $row->sources . '</td></tr>';
            }
            $h .= '</table>';
        }

        // Anomaly highlights
        if ( ! empty( $data['recent_anomalies'] ) ) {
            $h .= self::digest_heading( 'Anomaly highlights', '24h' );
            $h .= $open_tbl . '<tr><td style="' . $th . '">When (UTC)</td><td style="' . $th . '">User</td><td style="' . $th . '">From</td><td style="' . $thr . '">Score</td><td style="' . $th . '">Why</td></tr>';
            foreach ( $data['recent_anomalies'] as $a ) {
                $ctx = isset( $a->ctx ) ? (array) $a->ctx : array();
                $h .= '<tr><td style="' . $td . 'white-space:nowrap;">' . $esc( $a->created_at ) . '</td>'
                    . '<td style="' . $td . '">' . $esc( $a->username ?: '?' ) . '</td>'
                    . '<td style="' . $td . 'font-family:Consolas,Menlo,monospace;">' . $esc( $a->ip ?: '?' ) . '</td>'
                    . '<td style="' . $tdr . 'font-weight:600;">' . (int) ( $ctx['top_score'] ?? 0 ) . '</td>'
                    . '<td style="' . $td . 'color:' . $muted . ';">' . $esc( (string) ( $ctx['top_metric'] ?? '' ) )
                    . ( isset( $ctx['z'] ) ? ' (z=' . number_format( (float) $ctx['z'], 2 ) . ')' : '' ) . '</td></tr>';
            }
            $h .= '</table>';
        }

        // Vulnerable components
        if ( class_exists( 'Shieldforge_VulnScanner' ) ) {
            $vc_active = Shieldforge_VulnScanner::active_critical_count();
            $cached    = Shieldforge_VulnScanner::get_cached_results();
            $has_any   = is_array( $cached ) && ( ! empty( $cached['plugins'] ) || ! empty( $cached['themes'] ) || ! empty( $cached['core'] ) );
            if ( $vc_active > 0 || $has_any ) {
                $h .= self::digest_heading( 'Vulnerable components', $vc_active . ' active critical' );
                $h .= $open_tbl . '<tr><td style="' . $th . '">Type</td><td style="' . $th . '">Component</td><td style="' . $th . '">Version</td><td style="' . $thr . '">Vulns</td><td style="' . $th . '">Fix in</td></tr>';
                if ( is_array( $cached ) ) {
                    foreach ( array( 'plugins', 'themes', 'core' ) as $bucket ) {
                        foreach ( (array) ( $cached[ $bucket ] ?? array() ) as $entry ) {
                            if ( empty( $entry['active'] ) ) { continue; }
                            $h .= '<tr><td style="' . $td . 'color:' . $muted . ';">' . esc_html( substr( $bucket, 0, -1 ) ) . '</td>'
                                . '<td style="' . $td . '">' . $esc( $entry['name'] ) . '</td>'
                                . '<td style="' . $td . 'color:' . $muted . ';">' . $esc( $entry['version'] ) . '</td>'
                                . '<td style="' . $tdr . 'color:' . $red . ';font-weight:700;">' . (int) count( (array) ( $entry['vulns'] ?? array() ) ) . '</td>'
                                . '<td style="' . $td . '">' . $esc( $entry['vulns'][0]['fixed_in'] ?? '—' ) . '</td></tr>';
                        }
                    }
                }
                $h .= '</table>';
            }
        }

        // File-integrity highlights
        $fi_total = (int) ( $snapshot['file_added'] ?? 0 ) + (int) ( $snapshot['file_changed'] ?? 0 )
            + (int) ( $snapshot['file_missing'] ?? 0 ) + (int) ( $snapshot['malware_suspect'] ?? 0 );
        if ( $fi_total > 0 ) {
            $h .= self::digest_heading( 'File-integrity highlights', '24h' );
            $h .= $open_tbl . '<tr><td style="' . $th . '">Added</td><td style="' . $th . '">Changed</td><td style="' . $th . '">Missing</td><td style="' . $th . '">Suspicious patterns</td></tr>';
            $h .= '<tr><td style="' . $td . '">' . (int) ( $snapshot['file_added'] ?? 0 ) . '</td>'
                . '<td style="' . $td . '">' . (int) ( $snapshot['file_changed'] ?? 0 ) . '</td>'
                . '<td style="' . $td . '">' . (int) ( $snapshot['file_missing'] ?? 0 ) . '</td>'
                . '<td style="' . $td . 'color:' . ( (int) ( $snapshot['malware_suspect'] ?? 0 ) > 0 ? $red : $text ) . ';font-weight:600;">' . (int) ( $snapshot['malware_suspect'] ?? 0 ) . '</td></tr>';
            $h .= '</table>';
        }

        // Interpretation callout
        $h .= '<div style="margin-top:18px;padding:13px 15px;background:#f6f7f7;border-left:3px solid ' . $tc . ';border-radius:4px;font-size:13px;color:' . $text . ';' . $f . '">'
            . '<strong style="display:block;margin-bottom:3px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:' . $muted . ';">What this means</strong>'
            . $esc( self::interpretation( $score, $snapshot ) ) . '</div>';

        // CTA
        $h .= '<div style="text-align:center;margin:22px 0 4px;">'
            . '<a href="' . esc_url( admin_url( 'admin.php?page=brainwerk-security-suite' ) ) . '" style="display:inline-block;background:#2271b1;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:11px 26px;border-radius:6px;' . $f . '">Open the dashboard</a></div>';

        $h .= '</td></tr>';

        // Footer
        $h .= '<tr><td style="padding:14px 22px;background:#f6f7f7;border-top:1px solid ' . $border . ';font-size:11px;color:' . $muted . ';' . $f . '">'
            . 'You receive this because <em>Daily digest</em> is enabled in Brainwerk Security Suite &rarr; Notifications. Disable it there to stop.</td></tr>';

        $h .= '</table></td></tr></table></div>';
        return $h;
    }

    public static function peak_hour( array $hourly ) {
        $max = -1;
        $idx = null;
        foreach ( $hourly as $h => $n ) {
            if ( $n > $max ) {
                $max = $n;
                $idx = $h;
            }
        }
        return $max > 0 ? $idx : null;
    }

    private static function truncate( $s, $n ) {
        $s = (string) $s;
        if ( mb_strlen( $s ) <= $n ) {
            return $s;
        }
        return mb_substr( $s, 0, max( 1, $n - 3 ) ) . '...';
    }

    /**
     * Short, human interpretation hint based on the dominant signal —
     * tells the admin where to look first.
     */
    public static function interpretation( array $score, array $snapshot ) {
        $components = $score['components'];
        // Pick the component that contributed the most points.
        $top_key = null;
        $top_pts = -1;
        foreach ( $components as $k => $c ) {
            if ( $c['points'] > $top_pts ) {
                $top_pts = $c['points'];
                $top_key = $k;
            }
        }

        if ( self::LABEL_CALM === $score['label'] ) {
            return 'Quiet day. Nothing is on fire -keep monitoring.';
        }

        switch ( $top_key ) {
            case 'failed_login_volume':
                return sprintf(
                    'Driver: high failed-login volume (%d in 24h). Check the Top targeted usernames and Top attacker IPs lists; consider raising bruteforce_threshold or shortening the window.',
                    (int) $snapshot['logins_fail']
                );
            case 'distinct_attackers':
                return sprintf(
                    'Driver: many distinct attacker IPs (%d). This is broader than a single botnet -likely opportunistic scanners. Geo-blocking or fail2ban-style aggregation would help (see Pro).',
                    (int) $snapshot['distinct_ips']
                );
            case 'probing_density':
                return sprintf(
                    'Driver: heavy URI probing (%d 404s in 24h). Review Top probing URI patterns -repeated hits on /.env, /wp-config.bak, /xmlrpc.php confirm automated scanners.',
                    (int) $snapshot['probing_404s']
                );
            case 'escalation_signals':
                $malware = (int) ( $snapshot['malware_suspect'] ?? 0 );
                $files   = (int) ( $snapshot['file_changed'] ?? 0 ) + (int) ( $snapshot['file_missing'] ?? 0 );
                if ( $malware > 0 || $files > 0 ) {
                    return sprintf(
                        'Driver: file-system signals (%d suspicious-pattern hits, %d file changes/missing) on top of %d lockouts and %d anomalies. Open the Scanner tab FIRST -an unexplained file change in core or a webshell hit is your highest-priority review.',
                        $malware,
                        $files,
                        (int) $snapshot['lockouts'],
                        (int) $snapshot['anomalies']
                    );
                }
                return sprintf(
                    'Driver: escalation signals (%d lockouts, %d anomalies, %d blocked logins). The protections fired -check whether any anomaly has a familiar username; that is your highest-priority review.',
                    (int) $snapshot['lockouts'],
                    (int) $snapshot['anomalies'],
                    (int) $snapshot['logins_blocked']
                );
            default:
                return 'Activity above baseline -review the tables above.';
        }
    }
}
