<?php
/**
 * PHS_CVEScanner
 *
 * Checks installed plugins against the Wordfence Intelligence API (free, no key).
 * Optionally uses the WPScan API for richer detail when an API token is provided.
 *
 * Results are cached as transients (12h) so the API is not hit on every scan.
 * Each vulnerability is checked against the installed plugin version to determine
 * if the site is actually affected.
 */
class PHS_CVEScanner {

    const CACHE_PREFIX  = 'phs_cve_';
    const CACHE_TTL     = 12 * HOUR_IN_SECONDS;
    const WF_API        = 'https://www.wordfence.com/api/intelligence/v2/vulnerabilities/plugin/';
    const WPSCAN_API    = 'https://wpscan.com/api/v1/plugins/';
    const OPT_API_KEY   = 'phs_wpscan_api_key';
    const OPT_ENABLED   = 'phs_cve_enabled';

    // ── Public API ────────────────────────────────────────────────────────────

    /**
     * Enrich a plugins array (from PHS_Scanner::run()) with CVE data.
     * Adds `cve_count`, `cve_critical`, `cves` keys to each plugin entry.
     * Skips enrichment if CVE scanning is disabled in settings.
     */
    public static function enrich( array &$plugins ): void {
        if ( ! get_option( self::OPT_ENABLED, true ) ) return;

        foreach ( $plugins as &$p ) {
            $slug    = dirname( $p['file'] );
            if ( $slug === '.' ) $slug = basename( $p['file'], '.php' );

            $vulns = self::get_vulnerabilities( $slug, $p['version'] ?? '' );

            $p['cves']         = $vulns;
            $p['cve_count']    = count( $vulns );
            $p['cve_critical'] = count( array_filter( $vulns, fn( $v ) => ( $v['cvss'] ?? 0 ) >= 7.0 ) );
        }
        unset( $p );
    }

    /**
     * Returns count of plugins that have at least one CVE.
     */
    public static function count_vulnerable( array $plugins ): int {
        return count( array_filter( $plugins, fn( $p ) => ( $p['cve_count'] ?? 0 ) > 0 ) );
    }

    /**
     * Fetch (and cache) vulnerabilities for a single plugin slug.
     * Filters the list to only those affecting the installed version.
     *
     * @return array[] Each item: [id, title, cvss, cve, severity, patched, description, link]
     */
    public static function get_vulnerabilities( string $slug, string $installed_version ): array {
        if ( ! $slug ) return [];

        $cache_key = self::CACHE_PREFIX . md5( $slug );
        $cached    = get_transient( $cache_key );

        if ( $cached !== false ) {
            return self::filter_by_version( $cached, $installed_version );
        }

        $raw = self::fetch_wordfence( $slug );

        // Fallback to WPScan if Wordfence returned nothing and an API key exists
        if ( empty( $raw ) ) {
            $key = get_option( self::OPT_API_KEY, '' );
            if ( $key ) $raw = self::fetch_wpscan( $slug, $key );
        }

        // Cache the raw (unfiltered) list so we can re-filter on version changes
        set_transient( $cache_key, $raw ?: [], self::CACHE_TTL );

        return self::filter_by_version( $raw ?: [], $installed_version );
    }

    /**
     * Wipe cached CVE data for all plugins (forces fresh API check on next scan).
     */
    public static function flush_cache(): void {
        global $wpdb;
        $wpdb->query(
            "DELETE FROM {$wpdb->options}
              WHERE option_name LIKE '_transient_" . self::CACHE_PREFIX . "%'
                 OR option_name LIKE '_transient_timeout_" . self::CACHE_PREFIX . "%'"
        );
    }

    // ── Version matching ──────────────────────────────────────────────────────

    private static function filter_by_version( array $vulns, string $installed ): array {
        if ( empty( $installed ) ) return $vulns; // can't filter without a version
        $out = [];
        foreach ( $vulns as $v ) {
            if ( self::version_is_affected( $installed, $v['affected_versions'] ?? [] ) ) {
                $out[] = $v;
            }
        }
        return $out;
    }

    /**
     * Checks whether $installed falls within any of the affected version ranges.
     * Ranges come from the Wordfence API structure:
     *   { "*": { from_version, from_inclusive, to_version, to_inclusive } }
     */
    private static function version_is_affected( string $installed, array $ranges ): bool {
        if ( empty( $ranges ) ) return true; // no range = all versions affected

        foreach ( $ranges as $range ) {
            $from     = $range['from_version']    ?? '*';
            $from_inc = $range['from_inclusive']  ?? true;
            $to       = $range['to_version']      ?? '*';
            $to_inc   = $range['to_inclusive']    ?? true;

            $from_ok = ( $from === '*' ) || (
                $from_inc
                    ? version_compare( $installed, $from, '>=' )
                    : version_compare( $installed, $from, '>' )
            );
            $to_ok = ( $to === '*' ) || (
                $to_inc
                    ? version_compare( $installed, $to, '<=' )
                    : version_compare( $installed, $to, '<' )
            );

            if ( $from_ok && $to_ok ) return true;
        }
        return false;
    }

    // ── API fetchers ──────────────────────────────────────────────────────────

    private static function fetch_wordfence( string $slug ): array {
        $url = self::WF_API . rawurlencode( $slug );
        $res = wp_remote_get( $url, [
            'timeout'    => 10,
            'user-agent' => 'MasterHealthScanner/' . PHS_VERSION . '; ' . home_url(),
        ] );

        if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) !== 200 ) {
            return [];
        }

        $body = json_decode( wp_remote_retrieve_body( $res ), true );
        if ( ! is_array( $body ) ) return [];

        return self::parse_wordfence( $body );
    }

    private static function parse_wordfence( array $body ): array {
        $out = [];
        // Wordfence v2 returns an object keyed by UUID
        foreach ( $body as $id => $vuln ) {
            if ( ! is_array( $vuln ) ) continue;

            $cvss = (float) ( $vuln['cvss']['score'] ?? $vuln['cvss_score'] ?? 0 );
            $out[] = [
                'id'               => (string) $id,
                'title'            => (string) ( $vuln['title'] ?? 'Unknown vulnerability' ),
                'cve'              => (string) ( $vuln['cve'] ?? '' ),
                'cvss'             => $cvss,
                'severity'         => self::cvss_to_severity( $cvss ),
                'description'      => (string) ( $vuln['description'] ?? '' ),
                'patched_versions' => (array)  ( $vuln['patched_versions'] ?? [] ),
                'affected_versions'=> (array)  ( $vuln['affected_versions'] ?? [] ),
                'link'             => 'https://www.wordfence.com/threat-intel/vulnerabilities/id/' . rawurlencode( (string) $id ),
                'source'           => 'Wordfence',
            ];
        }
        return $out;
    }

    private static function fetch_wpscan( string $slug, string $api_key ): array {
        $url = self::WPSCAN_API . rawurlencode( $slug );
        $res = wp_remote_get( $url, [
            'timeout'    => 10,
            'headers'    => [ 'Authorization' => 'Token token=' . $api_key ],
            'user-agent' => 'MasterHealthScanner/' . PHS_VERSION . '; ' . home_url(),
        ] );

        if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) !== 200 ) {
            return [];
        }

        $body = json_decode( wp_remote_retrieve_body( $res ), true );
        if ( ! is_array( $body ) ) return [];

        return self::parse_wpscan( $body, $slug );
    }

    private static function parse_wpscan( array $body, string $slug ): array {
        $out   = [];
        $plugs = $body[ $slug ] ?? $body;
        $vulns = $plugs['vulnerabilities'] ?? [];

        foreach ( $vulns as $vuln ) {
            $cvss = (float) ( $vuln['cvss']['score'] ?? 0 );
            $out[] = [
                'id'               => (string) ( $vuln['id'] ?? '' ),
                'title'            => (string) ( $vuln['title'] ?? 'Unknown vulnerability' ),
                'cve'              => (string) ( $vuln['references']['cve'][0] ?? '' ),
                'cvss'             => $cvss,
                'severity'         => self::cvss_to_severity( $cvss ),
                'description'      => '',
                'patched_versions' => isset( $vuln['fixed_in'] ) ? [ $vuln['fixed_in'] ] : [],
                'affected_versions'=> [], // WPScan doesn't give ranges in same format
                'link'             => 'https://wpscan.com/vulnerability/' . rawurlencode( (string) ( $vuln['id'] ?? '' ) ),
                'source'           => 'WPScan',
            ];
        }
        return $out;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private static function cvss_to_severity( float $score ): string {
        if ( $score >= 9.0 ) return 'critical';
        if ( $score >= 7.0 ) return 'high';
        if ( $score >= 4.0 ) return 'medium';
        if ( $score > 0 )    return 'low';
        return 'unknown';
    }
}
