<?php
/**
 * Plugin / Theme / Core vulnerability scanner.
 *
 * Walks the local install (active + inactive plugins, themes, core), queries
 * our own EU vulnerability backend (shieldforge-intel.brainwerk.at — proxies
 * a full EU vuln index, sources cleaned to CVE + EUVD only) for each
 * component, and emits HIGH / CRITICAL events for any installed version that
 * matches a known vuln.
 *
 * Data we send out, per scan:
 *   - one HTTP GET per slug to https://shieldforge-intel.brainwerk.at/v1/vulns/plugin/{slug}/
 *   - same for /theme/{slug}/ and /core/{version}/
 *   - 250ms between calls to stay polite
 *
 * Data we do NOT send:
 *   - site URL, admin email, IPs, user info, or any identifier of the
 *     installation. Slugs are public information (anyone with read access
 *     to /wp-content/plugins/ can see them).
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_VulnScanner {

    const CRON_HOOK         = 'shieldforge_daily_vulnscan';
    const ENDPOINT_BASE     = 'https://shieldforge-intel.brainwerk.at/v1/vulns';
    const TRANSIENT_RESULT  = 'shieldforge_vulns_v1';
    const OPTION_LOGGED     = 'shieldforge_vulns_logged';
    const TRANSIENT_TTL     = 25 * HOUR_IN_SECONDS;     // 25h so digest at 07:00 always sees fresh data
    const HTTP_TIMEOUT      = 10;
    const RATE_DELAY_US     = 250000;                    // 0.25s between requests
    const LOG_REPEAT_AFTER  = 7 * DAY_IN_SECONDS;        // re-log a known vuln after a week so it doesn't fall off the radar

    public static function register( Shieldforge_Loader $loader ) {
        $loader->add_action( self::CRON_HOOK, __CLASS__, 'cron_run' );
    }

    public static function ensure_scheduled() {
        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            // 04:30 — staggered between scanner (03:30) and digest (07:00).
            wp_schedule_event( strtotime( 'tomorrow 04:30' ), 'daily', self::CRON_HOOK );
        }
    }

    public static function unschedule() {
        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( $next ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
        }
    }

    public static function cron_run() {
        if ( ! Shieldforge_Settings::get( 'vulnscan_enabled', false ) ) {
            return;
        }
        self::scan_all();
    }

    /* ==================================================================
     *  Scan orchestration
     * ================================================================== */

    public static function scan_all() {
        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $started = time();
        $results = array(
            'plugins'     => array(),
            'themes'      => array(),
            'core'        => array(),
            'errors'      => 0,
            'queries'     => 0,
            'finished_at' => null,
        );

        // ---- Plugins -------------------------------------------------
        foreach ( get_plugins() as $file => $meta ) {
            $slug    = self::derive_plugin_slug( $file );
            $version = isset( $meta['Version'] ) ? (string) $meta['Version'] : '';
            if ( '' === $version ) {
                continue;
            }
            $results['queries']++;
            $vulns = self::query_plugin( $slug );
            if ( false === $vulns ) {
                $results['errors']++;
                continue;
            }
            $matched = self::match_vulns( $vulns, $version );
            if ( ! empty( $matched ) ) {
                $is_active   = is_plugin_active( $file );
                $is_network  = is_multisite() && function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $file );
                $results['plugins'][] = array(
                    'slug'    => $slug,
                    'name'    => isset( $meta['Name'] ) ? (string) $meta['Name'] : $slug,
                    'version' => $version,
                    'active'  => (bool) ( $is_active || $is_network ),
                    'vulns'   => self::compact_vulns( $matched ),
                );
                self::log_findings( 'plugin', $slug, $version, $matched, (bool) ( $is_active || $is_network ) );
            }
        }

        // ---- Themes --------------------------------------------------
        if ( Shieldforge_Settings::get( 'vulnscan_check_themes', true ) ) {
            $active_theme_slug   = (string) get_option( 'stylesheet' );
            $active_template     = (string) get_option( 'template' );
            foreach ( wp_get_themes() as $stylesheet => $theme ) {
                $slug    = (string) $stylesheet;
                $version = (string) $theme->get( 'Version' );
                if ( '' === $version ) {
                    continue;
                }
                $results['queries']++;
                $vulns = self::query_theme( $slug );
                if ( false === $vulns ) {
                    $results['errors']++;
                    continue;
                }
                $matched = self::match_vulns( $vulns, $version );
                if ( ! empty( $matched ) ) {
                    $is_active = ( $slug === $active_theme_slug || $slug === $active_template );
                    $results['themes'][] = array(
                        'slug'    => $slug,
                        'name'    => (string) $theme->get( 'Name' ),
                        'version' => $version,
                        'active'  => $is_active,
                        'vulns'   => self::compact_vulns( $matched ),
                    );
                    self::log_findings( 'theme', $slug, $version, $matched, $is_active );
                }
            }
        }

        // ---- Core ----------------------------------------------------
        if ( Shieldforge_Settings::get( 'vulnscan_check_core', true ) ) {
            $core_version = (string) get_bloginfo( 'version' );
            if ( '' !== $core_version ) {
                $results['queries']++;
                $vulns = self::query_core( $core_version );
                if ( false === $vulns ) {
                    $results['errors']++;
                } else {
                    // Core endpoint already returns vulns affecting the
                    // queried version — no client-side range matching needed.
                    if ( ! empty( $vulns ) ) {
                        $results['core'][] = array(
                            'slug'    => 'wordpress',
                            'name'    => 'WordPress core',
                            'version' => $core_version,
                            'active'  => true,
                            'vulns'   => self::compact_vulns( $vulns ),
                        );
                        self::log_findings( 'core', 'wordpress', $core_version, $vulns, true );
                    }
                }
            }
        }

        $results['finished_at'] = time();
        set_transient( self::TRANSIENT_RESULT, $results, self::TRANSIENT_TTL );

        Shieldforge_Logger::log( Shieldforge_Logger::EVT_VULNSCAN_DONE, array(
            'severity' => Shieldforge_Logger::SEV_INFO,
            'context'  => array(
                'duration_s'        => time() - $started,
                'queries'           => $results['queries'],
                'errors'            => $results['errors'],
                'plugins_affected'  => count( $results['plugins'] ),
                'themes_affected'   => count( $results['themes'] ),
                'core_affected'     => count( $results['core'] ),
            ),
        ) );

        self::prune_logged_state();
        return $results;
    }

    /* ==================================================================
     *  HTTP layer
     * ================================================================== */

    private static function endpoint_base() {
        $opt = (string) Shieldforge_Settings::get( 'vulnscan_endpoint', self::ENDPOINT_BASE );
        $opt = rtrim( $opt, '/' );
        return '' === $opt ? self::ENDPOINT_BASE : $opt;
    }

    private static function query_plugin( $slug ) {
        return self::query( self::endpoint_base() . '/plugin/' . rawurlencode( $slug ) . '/' );
    }

    private static function query_theme( $slug ) {
        return self::query( self::endpoint_base() . '/theme/' . rawurlencode( $slug ) . '/' );
    }

    private static function query_core( $version ) {
        return self::query( self::endpoint_base() . '/core/' . rawurlencode( $version ) . '/' );
    }

    /**
     * GET the URL, decode JSON, return the inner vulnerability array.
     * Returns:
     *   array  on success (possibly empty)
     *   false  on transport/HTTP error
     */
    private static function query( $url ) {
        $args = array(
            'timeout'    => self::HTTP_TIMEOUT,
            'user-agent' => 'Brainwerk Security Suite/' . ( defined( 'SHIELDFORGE_VERSION' ) ? SHIELDFORGE_VERSION : 'dev' ) . ' (+https://security-suite.brainwerk.at)',
            'headers'    => array( 'Accept' => 'application/json' ),
        );
        $resp = wp_safe_remote_get( $url, $args );
        if ( is_wp_error( $resp ) ) {
            return false;
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );
        // 404 from the API means "no record for this slug" — treat as
        // empty result, not error. Anything else non-2xx is an error.
        if ( 404 === $code ) {
            return array();
        }
        if ( $code < 200 || $code >= 300 ) {
            return false;
        }
        $body = (string) wp_remote_retrieve_body( $resp );
        $json = json_decode( $body, true );
        if ( ! is_array( $json ) ) {
            return false;
        }
        // Two shapes:
        //   plugin/theme: data.vulnerability[]
        //   core: data.vulnerability[] OR data[] (defensive)
        $vulns = array();
        if ( isset( $json['data']['vulnerability'] ) && is_array( $json['data']['vulnerability'] ) ) {
            $vulns = $json['data']['vulnerability'];
        } elseif ( isset( $json['data'] ) && is_array( $json['data'] ) && isset( $json['data'][0] ) ) {
            $vulns = $json['data'];
        }
        usleep( self::RATE_DELAY_US );
        return $vulns;
    }

    /* ==================================================================
     *  Version-range matching
     * ================================================================== */

    /**
     * @param array  $vulns           list of vuln entries from the API
     * @param string $current_version the version installed locally
     * @return array subset that affect $current_version
     */
    public static function match_vulns( array $vulns, $current_version ) {
        $matched = array();
        foreach ( $vulns as $v ) {
            $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array();
            if ( self::version_in_range( $current_version, $op ) ) {
                $matched[] = $v;
            }
        }
        return $matched;
    }

    public static function version_in_range( $version, array $op ) {
        $min_v = isset( $op['min_version'] ) ? $op['min_version'] : null;
        $min_o = isset( $op['min_operator'] ) ? $op['min_operator'] : null;
        $max_v = isset( $op['max_version'] ) ? $op['max_version'] : null;
        $max_o = isset( $op['max_operator'] ) ? $op['max_operator'] : null;

        // No constraints at all → "all versions affected" (rare but happens
        // for plugins removed from the repo with unfixed=1).
        if ( empty( $min_v ) && empty( $max_v ) ) {
            return true;
        }
        $ok_min = true;
        $ok_max = true;
        if ( $min_v ) {
            $ok_min = version_compare( $version, $min_v, self::map_operator( $min_o, '>=' ) );
        }
        if ( $max_v ) {
            $ok_max = version_compare( $version, $max_v, self::map_operator( $max_o, '<=' ) );
        }
        return $ok_min && $ok_max;
    }

    private static function map_operator( $op, $default ) {
        switch ( $op ) {
            case 'lt': return '<';
            case 'le': return '<=';
            case 'gt': return '>';
            case 'ge': return '>=';
            case 'eq': return '=';
        }
        return $default;
    }

    /* ==================================================================
     *  Slug + payload helpers
     * ================================================================== */

    public static function derive_plugin_slug( $plugin_file ) {
        $plugin_file = (string) $plugin_file;
        if ( false !== strpos( $plugin_file, '/' ) ) {
            return dirname( $plugin_file );
        }
        return basename( $plugin_file, '.php' );
    }

    /**
     * Trim a vuln down to just the fields we care about for storage / UI.
     * Keeps the transient payload small (some plugins have 50+ historic
     * vulns and we don't need their full description blob).
     */
    public static function compact_vulns( array $vulns ) {
        $out = array();
        foreach ( $vulns as $v ) {
            $sources = array();
            if ( ! empty( $v['source'] ) && is_array( $v['source'] ) ) {
                foreach ( $v['source'] as $s ) {
                    $sources[] = array(
                        'id'   => isset( $s['id'] ) ? (string) $s['id'] : '',
                        'name' => isset( $s['name'] ) ? (string) $s['name'] : '',
                        'link' => isset( $s['link'] ) ? esc_url_raw( $s['link'] ) : '',
                    );
                }
            }
            $cvss = '';
            if ( isset( $v['impact']['cvss3']['score'] ) ) {
                $cvss = (string) $v['impact']['cvss3']['score'];
            } elseif ( isset( $v['impact']['cvss']['score'] ) ) {
                $cvss = (string) $v['impact']['cvss']['score'];
            }
            $out[] = array(
                'uuid'     => isset( $v['uuid'] ) ? (string) $v['uuid'] : '',
                'name'     => isset( $v['name'] ) ? (string) $v['name'] : '',
                'cvss'     => $cvss,
                'fixed_in' => self::derive_fixed_in( $v ),
                'sources'  => $sources,
                'unfixed'  => ! empty( $v['operator']['unfixed'] ) && '0' !== (string) $v['operator']['unfixed'],
            );
        }
        return $out;
    }

    /**
     * "fixed in" = the version where the bug got patched. The API exposes
     * this implicitly: max_version with operator 'lt' means "anything
     * below max_version is vulnerable" → max_version itself is the fix.
     */
    private static function derive_fixed_in( $v ) {
        if ( ! empty( $v['operator']['unfixed'] ) && '0' !== (string) $v['operator']['unfixed'] ) {
            return ''; // not yet patched
        }
        $max_v = isset( $v['operator']['max_version'] ) ? (string) $v['operator']['max_version'] : '';
        $max_o = isset( $v['operator']['max_operator'] ) ? (string) $v['operator']['max_operator'] : '';
        if ( '' === $max_v ) {
            return '';
        }
        // 'lt' max → fix is max itself; 'le' max → fix is one bump above.
        // We don't have semver-bump info, so for 'le' we just show max_v
        // with a trailing '+' to hint "anything later than this".
        return ( 'lt' === $max_o ) ? $max_v : $max_v . '+';
    }

    /* ==================================================================
     *  Log dedup
     * ================================================================== */

    /**
     * Log each vuln once per slug+version+uuid every LOG_REPEAT_AFTER. Keeps
     * the event stream useful (don't drown the log in 100x daily repeats)
     * but still surfaces unpatched vulns periodically.
     */
    private static function log_findings( $type, $slug, $version, array $vulns, $is_active ) {
        $logged = (array) get_option( self::OPTION_LOGGED, array() );
        $now    = time();
        foreach ( $vulns as $v ) {
            $uuid = isset( $v['uuid'] ) ? (string) $v['uuid'] : md5( wp_json_encode( $v ) );
            $key  = $type . ':' . $slug . ':' . $version . ':' . $uuid;
            $last = isset( $logged[ $key ] ) ? (int) $logged[ $key ] : 0;
            if ( $last && ( $now - $last ) < self::LOG_REPEAT_AFTER ) {
                continue; // already logged recently
            }
            $logged[ $key ] = $now;
            $sev = $is_active
                ? ( 'core' === $type ? Shieldforge_Logger::SEV_CRITICAL : Shieldforge_Logger::SEV_CRITICAL )
                : Shieldforge_Logger::SEV_HIGH;
            $sources = array();
            if ( ! empty( $v['source'] ) && is_array( $v['source'] ) ) {
                foreach ( $v['source'] as $s ) {
                    if ( ! empty( $s['id'] ) ) {
                        $sources[] = (string) $s['id'];
                    }
                }
            }
            Shieldforge_Logger::log( Shieldforge_Logger::EVT_VULNERABILITY_FOUND, array(
                'severity' => $sev,
                'context'  => array(
                    'type'     => $type,
                    'slug'     => $slug,
                    'version'  => $version,
                    'active'   => (bool) $is_active,
                    'uuid'     => $uuid,
                    'name'     => isset( $v['name'] ) ? (string) $v['name'] : '',
                    'sources'  => $sources,
                    'fixed_in' => self::derive_fixed_in( $v ),
                ),
            ) );
        }
        update_option( self::OPTION_LOGGED, $logged, false );
    }

    /**
     * Drop logged-state entries older than 30 days so the option doesn't
     * grow without bound on a long-lived install.
     */
    private static function prune_logged_state() {
        $logged = (array) get_option( self::OPTION_LOGGED, array() );
        $cutoff = time() - 30 * DAY_IN_SECONDS;
        $kept   = array();
        foreach ( $logged as $k => $ts ) {
            if ( (int) $ts >= $cutoff ) {
                $kept[ $k ] = (int) $ts;
            }
        }
        if ( count( $kept ) !== count( $logged ) ) {
            update_option( self::OPTION_LOGGED, $kept, false );
        }
    }

    /* ==================================================================
     *  Read API for UI / dashboard / digest
     * ================================================================== */

    public static function get_cached_results() {
        $r = get_transient( self::TRANSIENT_RESULT );
        return is_array( $r ) ? $r : null;
    }

    public static function status() {
        $r = self::get_cached_results();
        $next = wp_next_scheduled( self::CRON_HOOK );
        return array(
            'enabled'     => (bool) Shieldforge_Settings::get( 'vulnscan_enabled', false ),
            'last_run'    => $r && ! empty( $r['finished_at'] ) ? (int) $r['finished_at'] : null,
            'plugins'     => $r ? count( $r['plugins'] ) : 0,
            'themes'      => $r ? count( $r['themes'] ) : 0,
            'core'        => $r ? count( $r['core'] ) : 0,
            'errors'      => $r ? (int) ( $r['errors'] ?? 0 ) : 0,
            'queries'     => $r ? (int) ( $r['queries'] ?? 0 ) : 0,
            'next_run'    => $next ? (int) $next : null,
            'has_data'    => null !== $r,
        );
    }

    /**
     * "active critical" = number of vuln entries on currently-active components.
     * This is the number worth nagging the admin about on the dashboard.
     */
    public static function active_critical_count() {
        $r = self::get_cached_results();
        if ( ! $r ) {
            return 0;
        }
        $n = 0;
        foreach ( array( 'plugins', 'themes', 'core' ) as $bucket ) {
            foreach ( (array) ( $r[ $bucket ] ?? array() ) as $entry ) {
                if ( ! empty( $entry['active'] ) ) {
                    $n += count( (array) ( $entry['vulns'] ?? array() ) );
                }
            }
        }
        return $n;
    }

    public static function reset_cache() {
        delete_transient( self::TRANSIENT_RESULT );
        delete_option( self::OPTION_LOGGED );
    }
}
