<?php
/**
 * Repo Integrity — verifies installed plugins and themes against the
 * official wordpress.org distribution and restores tampered files.
 *
 * Ergänzt den Core-Compare (wp-admin/wp-includes) um die zwei Flächen, auf
 * denen Malware real landet: Plugin- und Theme-Ordner.
 *
 * Quellen:
 *  - Plugins: offizielle Checksums-API
 *      https://downloads.wordpress.org/plugin-checksums/{slug}/{version}.json
 *  - Themes: wp.org bietet KEINE Checksums-API — wir laden das Original-ZIP
 *      https://downloads.wordpress.org/theme/{slug}.{version}.zip
 *    einmalig, bauen daraus ein MD5-Manifest und cachen es 1 Woche.
 *
 * Repair (pro Datei, admin-gated):
 *  1. Original von wp.org holen (Plugins: SVN-Raw, Fallback ZIP; Themes: ZIP)
 *  2. MD5 des Downloads gegen das Manifest verifizieren
 *  3. Bestehende Datei in die Quarantäne kopieren (uploads/, HTTP-gesperrt)
 *  4. Atomar ersetzen (temp + rename), Ergebnis-MD5 erneut verifizieren
 *
 * Nicht-wp.org-Plugins/-Themes (Premium, custom) werden als "unverifiable"
 * gemeldet, nie angefasst. Extra-Dateien (potenzielle Shells) werden nur
 * GEMELDET — Löschen bleibt bewusst eine manuelle Entscheidung.
 *
 * Default OFF (`repo_integrity_enabled` steuert nur den täglichen Cron;
 * der manuelle Scan-Button funktioniert immer).
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Repo_Integrity {

    const CRON_HOOK     = 'shieldforge_repo_integrity';
    const REPORT_OPTION = 'shieldforge_repo_integrity_report';
    const MANIFEST_TTL  = DAY_IN_SECONDS;
    const THEME_MANIFEST_TTL = WEEK_IN_SECONDS;

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

        if ( Shieldforge_Settings::get( 'repo_integrity_enabled', false ) ) {
            if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
                wp_schedule_event( strtotime( 'tomorrow 03:40' ), 'daily', self::CRON_HOOK );
            }
        } elseif ( wp_next_scheduled( self::CRON_HOOK ) ) {
            wp_clear_scheduled_hook( self::CRON_HOOK );
        }
    }

    public static function ajax_run() {
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
        if ( ! check_ajax_referer( 'shieldforge_admin', 'nonce', false ) ) wp_send_json_error( array( 'message' => 'bad nonce' ), 403 );
        $report = self::run();
        wp_send_json( array( 'ok' => true, 'summary' => $report['summary'] ) );
    }

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

    /* ---------------------------------------------------------------------
     * Scan
     * ------------------------------------------------------------------- */

    /**
     * Vergleicht alle installierten Plugins + Themes gegen wp.org und
     * persistiert den Report. Rückgabe: der Report.
     */
    public static function run() {
        $items = array();

        foreach ( self::installed_plugins() as $slug => $info ) {
            $items[] = self::check_plugin( $slug, $info );
        }
        foreach ( wp_get_themes() as $slug => $theme ) {
            $items[] = self::check_theme( $slug, $theme );
        }

        $summary = array( 'ok' => 0, 'mismatch' => 0, 'unverifiable' => 0, 'error' => 0, 'files_flagged' => 0 );
        foreach ( $items as $item ) {
            if ( isset( $summary[ $item['status'] ] ) ) $summary[ $item['status'] ]++;
            $summary['files_flagged'] += count( $item['modified'] ) + count( $item['missing'] ) + count( $item['extra'] );
        }

        $report = array(
            'items'      => $items,
            'summary'    => $summary,
            'checked_at' => gmdate( 'c' ),
        );
        self::save_report( $report );

        if ( $summary['mismatch'] > 0 ) {
            if ( class_exists( 'Shieldforge_Logger' ) ) {
                Shieldforge_Logger::log( 'repo_mismatch', array(
                    'severity' => Shieldforge_Logger::SEV_HIGH,
                    'context'  => array( 'packages_flagged' => $summary['mismatch'], 'files_flagged' => $summary['files_flagged'] ),
                ) );
            }
            if ( class_exists( 'Shieldforge_Event' ) ) {
                Shieldforge_Event::emit( array(
                    'event_type' => 'integrity.repo_mismatch',
                    'severity'   => 'high',
                    'target'     => array( 'kind' => 'filesystem', 'endpoint' => 'plugins+themes' ),
                    'context'    => array(
                        'packages_flagged' => $summary['mismatch'],
                        'files_flagged'    => $summary['files_flagged'],
                    ),
                ) );
            }
        }
        return $report;
    }

    /** Letzten gespeicherten Report holen (oder null). */
    public static function get_report() {
        $report = is_multisite() ? get_site_option( self::REPORT_OPTION, null ) : get_option( self::REPORT_OPTION, null );
        return is_array( $report ) ? $report : null;
    }

    private static function save_report( array $report ) {
        if ( is_multisite() ) {
            update_site_option( self::REPORT_OPTION, $report );
        } else {
            update_option( self::REPORT_OPTION, $report, false );
        }
    }

    /** [ slug => [version, name, file] ] für alle installierten Verzeichnis-Plugins. */
    private static function installed_plugins() {
        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }
        $out = array();
        foreach ( get_plugins() as $file => $data ) {
            $slug = dirname( $file );
            if ( '.' === $slug ) continue; // Single-File-Plugins haben keine Checksums
            if ( isset( $out[ $slug ] ) ) continue;
            $out[ $slug ] = array(
                'version' => isset( $data['Version'] ) ? (string) $data['Version'] : '',
                'name'    => isset( $data['Name'] ) ? (string) $data['Name'] : $slug,
                'file'    => $file,
            );
        }
        return $out;
    }

    private static function check_plugin( $slug, $info ) {
        $item = array(
            'type' => 'plugin', 'slug' => $slug, 'name' => $info['name'],
            'version' => $info['version'], 'status' => 'ok',
            'modified' => array(), 'missing' => array(), 'extra' => array(),
        );
        $manifest = self::plugin_manifest( $slug, $info['version'] );
        if ( is_wp_error( $manifest ) ) {
            $item['status'] = ( 'not_found' === $manifest->get_error_code() ) ? 'unverifiable' : 'error';
            $item['note']   = $manifest->get_error_message();
            return $item;
        }
        $base = trailingslashit( WP_PLUGIN_DIR ) . $slug . '/';
        return self::compare_tree( $item, $base, $manifest );
    }

    private static function check_theme( $slug, $theme ) {
        $item = array(
            'type' => 'theme', 'slug' => $slug, 'name' => $theme->get( 'Name' ),
            'version' => (string) $theme->get( 'Version' ), 'status' => 'ok',
            'modified' => array(), 'missing' => array(), 'extra' => array(),
        );
        $manifest = self::theme_manifest( $slug, $item['version'] );
        if ( is_wp_error( $manifest ) ) {
            $item['status'] = ( 'not_found' === $manifest->get_error_code() ) ? 'unverifiable' : 'error';
            $item['note']   = $manifest->get_error_message();
            return $item;
        }
        $base = trailingslashit( $theme->get_stylesheet_directory() );
        return self::compare_tree( $item, $base, $manifest );
    }

    /**
     * Manifest gegen das lokale Verzeichnis vergleichen.
     * $manifest: [ relativer Pfad => md5 | array von md5s ]
     */
    private static function compare_tree( array $item, $base, array $manifest ) {
        foreach ( $manifest as $rel => $md5s ) {
            $full = $base . $rel;
            if ( ! file_exists( $full ) ) {
                $item['missing'][] = $rel;
                continue;
            }
            $actual = @md5_file( $full ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file passed file_exists() above but may still be unreadable (race/permissions); failure yields false, handled by the false === $actual check below.
            $md5s   = (array) $md5s; // API liefert teils mehrere gültige Hashes (Zeilenende-Varianten)
            if ( false === $actual || ! in_array( $actual, $md5s, true ) ) {
                $item['modified'][] = $rel;
            }
        }

        // Extra-Dateien: ausführbare Typen im Paket-Ordner, die wp.org nicht
        // kennt — klassischer Lande-Platz für nachgeladene Webshells.
        if ( is_dir( $base ) ) {
            $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, RecursiveDirectoryIterator::SKIP_DOTS ) );
            foreach ( $iter as $file ) {
                if ( ! $file->isFile() ) continue;
                $ext = strtolower( pathinfo( $file->getFilename(), PATHINFO_EXTENSION ) );
                if ( ! in_array( $ext, array( 'php', 'phtml', 'phar', 'js' ), true ) ) continue;
                $rel = str_replace( '\\', '/', ltrim( substr( $file->getPathname(), strlen( $base ) ), '/\\' ) );
                if ( ! isset( $manifest[ $rel ] ) ) {
                    $item['extra'][] = $rel;
                }
            }
        }

        if ( $item['modified'] || $item['missing'] || $item['extra'] ) {
            $item['status'] = 'mismatch';
        }
        return $item;
    }

    /* ---------------------------------------------------------------------
     * Manifeste
     * ------------------------------------------------------------------- */

    /** Plugin-Manifest von der offiziellen Checksums-API (1 Tag gecacht). */
    private static function plugin_manifest( $slug, $version ) {
        if ( '' === $version ) return new WP_Error( 'no_version', 'no version header' );
        $key    = 'shieldforge_repo_pm_' . md5( $slug . '|' . $version );
        $cached = get_site_transient( $key );
        if ( is_array( $cached ) ) return $cached;

        $url = sprintf( 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json', rawurlencode( $slug ), rawurlencode( $version ) );
        $r   = wp_remote_get( $url, array( 'timeout' => 15 ) );
        if ( is_wp_error( $r ) ) return $r;
        $code = (int) wp_remote_retrieve_response_code( $r );
        if ( 404 === $code ) return new WP_Error( 'not_found', 'not on wordpress.org (or version not in SVN)' );
        if ( 200 !== $code ) return new WP_Error( 'http', 'checksums API returned ' . $code );

        $body = json_decode( (string) wp_remote_retrieve_body( $r ), true );
        if ( empty( $body['files'] ) || ! is_array( $body['files'] ) ) return new WP_Error( 'parse', 'invalid checksums response' );

        $manifest = array();
        foreach ( $body['files'] as $rel => $hashes ) {
            if ( isset( $hashes['md5'] ) ) {
                $manifest[ str_replace( '\\', '/', $rel ) ] = $hashes['md5'];
            }
        }
        set_site_transient( $key, $manifest, self::MANIFEST_TTL );
        return $manifest;
    }

    /**
     * Theme-Manifest: Original-ZIP laden und MD5s daraus bauen (1 Woche
     * gecacht — das ZIP wird also pro Theme-Version genau einmal geholt).
     */
    private static function theme_manifest( $slug, $version ) {
        if ( '' === $version ) return new WP_Error( 'no_version', 'no version header' );
        if ( ! class_exists( 'ZipArchive' ) ) return new WP_Error( 'no_zip', 'PHP zip extension missing' );
        $key    = 'shieldforge_repo_tm_' . md5( $slug . '|' . $version );
        $cached = get_site_transient( $key );
        if ( is_array( $cached ) ) return $cached;

        $tmp = self::download_zip( sprintf( 'https://downloads.wordpress.org/theme/%s.%s.zip', rawurlencode( $slug ), rawurlencode( $version ) ) );
        if ( is_wp_error( $tmp ) ) return $tmp;

        $manifest = array();
        $zip      = new ZipArchive();
        if ( true === $zip->open( $tmp ) ) {
            for ( $i = 0; $i < $zip->numFiles; $i++ ) {
                $stat = $zip->statIndex( $i );
                if ( ! $stat || substr( $stat['name'], -1 ) === '/' ) continue;
                $rel = $stat['name'];
                // Führendes "slug/" abschneiden
                if ( 0 === strpos( $rel, $slug . '/' ) ) $rel = substr( $rel, strlen( $slug ) + 1 );
                if ( '' === $rel ) continue;
                $content = $zip->getFromIndex( $i );
                if ( false !== $content ) {
                    $manifest[ str_replace( '\\', '/', $rel ) ] = md5( $content );
                }
            }
            $zip->close();
        }
        @unlink( $tmp );
        if ( empty( $manifest ) ) return new WP_Error( 'parse', 'could not read theme zip' );
        set_site_transient( $key, $manifest, self::THEME_MANIFEST_TTL );
        return $manifest;
    }

    /** ZIP nach temp laden. 404 → not_found (= nicht auf wp.org). */
    private static function download_zip( $url ) {
        if ( ! function_exists( 'download_url' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        $tmp = download_url( $url, 60 );
        if ( is_wp_error( $tmp ) ) {
            $data = $tmp->get_error_data();
            $code = is_array( $data ) && isset( $data['code'] ) ? (int) $data['code'] : 0;
            if ( 404 === $code || false !== strpos( $tmp->get_error_message(), '404' ) ) {
                return new WP_Error( 'not_found', 'not on wordpress.org' );
            }
            return $tmp;
        }
        return $tmp;
    }

}
