<?php
/**
 * Pre-launch sanity check — walks through every module and reports
 * misconfiguration / hygiene issues that would embarrass a reviewer or
 * trip up a customer.
 *
 * Each check returns a row:
 *   [ id, label, status (ok|warn|fail), message, fix_url (optional) ]
 *
 * Designed to be run from the Tools tab AND from wp-cli (future
 * `wp shieldforge sanity` command). No side effects — read-only.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Sanity {

    const STATUS_OK   = 'ok';
    const STATUS_WARN = 'warn';
    const STATUS_FAIL = 'fail';

    /**
     * Run all checks. Returns ordered array of result rows.
     */
    public static function run_all() {
        $results = array();

        // === Versioning + migration ===
        $results[] = self::check_version_constants();
        $results[] = self::check_db_migration();

        // === Cron health ===
        $results[] = self::check_cron_scheduled( 'shieldforge_daily_digest', __( 'Daily digest e-mail', 'brainwerk-security-suite' ) );
        $results[] = self::check_cron_scheduled( 'shieldforge_cleanup_logs', __( 'Daily log cleanup', 'brainwerk-security-suite' ) );
        $results[] = self::check_cron_scheduled( 'shieldforge_daily_scan', __( 'Daily file-integrity scan', 'brainwerk-security-suite' ) );
        $results[] = self::check_cron_scheduled( 'shieldforge_daily_vulnscan', __( 'Daily vulnerability scan', 'brainwerk-security-suite' ) );

        // === Database tables ===
        $results[] = self::check_db_tables();

        // === Security hygiene ===
        $results[] = self::check_force_pro_constant();
        $results[] = self::check_ip_anonymization();
        $results[] = self::check_log_retention();
        $results[] = self::check_critical_modules_on();
        $results[] = self::check_2fa_recovery_codes_present();
        $results[] = self::check_admin_2fa_coverage();

        // === Healthcheck (re-use existing module) ===
        // Force a fresh run when invoked from the Tools tab so the result
        // reflects the actual browser request, not a stale CLI-context cache
        // that would falsely report REMOTE_ADDR=127.0.0.1.
        if ( class_exists( 'Shieldforge_Healthcheck' ) ) {
            foreach ( Shieldforge_Healthcheck::run( true ) as $f ) {
                $results[] = array(
                    'id'      => 'healthcheck_' . md5( $f['title'] ?? '' ),
                    'label'   => $f['title'] ?? __( 'Healthcheck finding', 'brainwerk-security-suite' ),
                    'status'  => self::map_severity_to_status( $f['severity'] ?? 'info' ),
                    'message' => $f['body'] ?? ( $f['detail'] ?? '' ),
                    'fix_url' => null,
                );
            }
        }

        // === File hygiene ===
        $results[] = self::check_no_test_fixtures();

        // === wp.org submission readiness ===
        $results[] = self::check_readme_stable_tag();
        $results[] = self::check_translation_files_present();

        return array_values( array_filter( $results ) );
    }

    public static function summary() {
        $rows = self::run_all();
        $totals = array( 'ok' => 0, 'warn' => 0, 'fail' => 0 );
        foreach ( $rows as $r ) {
            $totals[ $r['status'] ] = ( $totals[ $r['status'] ] ?? 0 ) + 1;
        }
        return $totals;
    }

    /* ==================================================================
     *  Individual checks
     * ================================================================== */

    private static function check_version_constants() {
        $header_version = '';
        if ( function_exists( 'get_plugin_data' ) ) {
            $data = get_plugin_data( SHIELDFORGE_FILE, false, false );
            $header_version = $data['Version'] ?? '';
        }
        $const = defined( 'SHIELDFORGE_VERSION' ) ? SHIELDFORGE_VERSION : '';
        if ( $header_version && $const && $header_version !== $const ) {
            return array(
                'id'      => 'version_constant_mismatch',
                'label'   => __( 'Plugin-header version vs. SHIELDFORGE_VERSION constant', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_FAIL,
                'message' => sprintf(
                    /* translators: 1: header version, 2: constant version */
                    __( 'Header says %1$s but constant is %2$s. Re-run bin/bump-version to re-sync.', 'brainwerk-security-suite' ),
                    $header_version, $const
                ),
                'fix_url' => null,
            );
        }
        return array(
            'id'      => 'version_constant_match',
            'label'   => __( 'Plugin-header version vs. SHIELDFORGE_VERSION constant', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => sprintf( __( 'Both at %s.', 'brainwerk-security-suite' ), $const ?: '?' ),
            'fix_url' => null,
        );
    }

    private static function check_db_migration() {
        if ( ! class_exists( 'Shieldforge_Migrations' ) ) {
            return null;
        }
        $stored = Shieldforge_Migrations::get_db_version();
        $target = defined( 'SHIELDFORGE_DB_VERSION' ) ? SHIELDFORGE_DB_VERSION : '0';
        if ( version_compare( $stored, $target, '<' ) ) {
            return array(
                'id'      => 'db_migration_pending',
                'label'   => __( 'Database schema migration pending', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => sprintf( __( 'DB at v%1$s, code expects v%2$s. Visit any wp-admin page to trigger admin_init migration.', 'brainwerk-security-suite' ), $stored, $target ),
                'fix_url' => admin_url(),
            );
        }
        return array(
            'id'      => 'db_migration_ok',
            'label'   => __( 'Database schema migration', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => sprintf( __( 'DB schema at v%s — current.', 'brainwerk-security-suite' ), $target ),
            'fix_url' => null,
        );
    }

    private static function check_cron_scheduled( $hook, $label ) {
        $next = wp_next_scheduled( $hook );
        if ( $next ) {
            return array(
                'id'      => 'cron_' . $hook,
                'label'   => $label,
                'status'  => self::STATUS_OK,
                'message' => sprintf( __( 'Next run in %s.', 'brainwerk-security-suite' ), human_time_diff( time(), (int) $next ) ),
                'fix_url' => null,
            );
        }
        return array(
            'id'      => 'cron_' . $hook,
            'label'   => $label,
            'status'  => self::STATUS_WARN,
            'message' => __( 'Cron hook not scheduled. Deactivate + reactivate plugin to repair.', 'brainwerk-security-suite' ),
            'fix_url' => admin_url( 'plugins.php' ),
        );
    }

    private static function check_db_tables() {
        global $wpdb;
        $required = array(
            $wpdb->base_prefix . 'shieldforge_log',
            $wpdb->base_prefix . 'shieldforge_blocks',
            $wpdb->base_prefix . 'shieldforge_baseline',
            $wpdb->base_prefix . 'shieldforge_files',
        );
        $missing = array();
        foreach ( $required as $t ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- health-check probe of the plugin's own tables; direct live read by design; not cacheable.
            $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t ) );
            if ( $exists !== $t ) {
                $missing[] = $t;
            }
        }
        if ( $missing ) {
            return array(
                'id'      => 'db_tables_missing',
                'label'   => __( 'Required database tables', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_FAIL,
                'message' => sprintf( __( 'Missing: %s. Reactivate plugin to recreate.', 'brainwerk-security-suite' ), implode( ', ', $missing ) ),
                'fix_url' => admin_url( 'plugins.php' ),
            );
        }
        return array(
            'id'      => 'db_tables_ok',
            'label'   => __( 'Required database tables', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => sprintf( __( 'All %d core tables present.', 'brainwerk-security-suite' ), count( $required ) ),
            'fix_url' => null,
        );
    }

    private static function check_force_pro_constant() {
        if ( defined( 'SHIELDFORGE_FORCE_PRO' ) && SHIELDFORGE_FORCE_PRO ) {
            return array(
                'id'      => 'force_pro_active',
                'label'   => __( 'SHIELDFORGE_FORCE_PRO override', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => __( 'Constant is set in wp-config.php — Pro features are unlocked WITHOUT a real licence. Fine for testing, remove for production.', 'brainwerk-security-suite' ),
                'fix_url' => null,
            );
        }
        return array(
            'id'      => 'force_pro_inactive',
            'label'   => __( 'SHIELDFORGE_FORCE_PRO override', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => __( 'Not set — Pro features gate on real licence as intended.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    private static function check_ip_anonymization() {
        $on = (bool) Shieldforge_Settings::get( 'privacy_anonymize_ip', true );
        return array(
            'id'      => 'privacy_anon_ip',
            'label'   => __( 'IP anonymization (GDPR)', 'brainwerk-security-suite' ),
            'status'  => $on ? self::STATUS_OK : self::STATUS_WARN,
            'message' => $on
                ? __( 'On — IPv4 anonymized to /24, IPv6 to /64.', 'brainwerk-security-suite' )
                : __( 'Off — full IPs are stored. Verify your privacy policy reflects this; you may need explicit user consent under GDPR.', 'brainwerk-security-suite' ),
            'fix_url' => admin_url( 'admin.php?page=brainwerk-security-suite&tab=privacy' ),
        );
    }

    private static function check_log_retention() {
        $days = (int) Shieldforge_Settings::get( 'log_retention_days', 30 );
        if ( $days < 7 ) {
            return array(
                'id'      => 'log_retention_short',
                'label'   => __( 'Log retention window', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => sprintf( __( 'Only %d days — may be too short for incident-response forensics.', 'brainwerk-security-suite' ), $days ),
                'fix_url' => admin_url( 'admin.php?page=brainwerk-security-suite&tab=privacy' ),
            );
        }
        if ( $days > 365 ) {
            return array(
                'id'      => 'log_retention_long',
                'label'   => __( 'Log retention window', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => sprintf( __( '%d days is unusually long. GDPR data-minimization principles suggest the shortest period that still meets your business need.', 'brainwerk-security-suite' ), $days ),
                'fix_url' => admin_url( 'admin.php?page=brainwerk-security-suite&tab=privacy' ),
            );
        }
        return array(
            'id'      => 'log_retention_ok',
            'label'   => __( 'Log retention window', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => sprintf( __( '%d days — sensible default.', 'brainwerk-security-suite' ), $days ),
            'fix_url' => null,
        );
    }

    private static function check_critical_modules_on() {
        $bf = (bool) Shieldforge_Settings::get( 'bruteforce_enabled', true );
        $hh = (bool) Shieldforge_Settings::get( 'hardening_disable_xmlrpc', true );
        if ( ! $bf ) {
            return array(
                'id'      => 'bf_disabled',
                'label'   => __( 'Brute-force protection', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_FAIL,
                'message' => __( 'OFF — site is wide open to password-guessing. Turn on under Anmeldung tab.', 'brainwerk-security-suite' ),
                'fix_url' => admin_url( 'admin.php?page=brainwerk-security-suite&tab=login' ),
            );
        }
        if ( ! $hh ) {
            return array(
                'id'      => 'hardening_low',
                'label'   => __( 'Core hardening (XML-RPC etc.)', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => __( 'XML-RPC is on. Most sites don\'t need it; it\'s a common brute-force vector. Turn off under Härtung tab unless you use Jetpack mobile / mobile WordPress apps.', 'brainwerk-security-suite' ),
                'fix_url' => admin_url( 'admin.php?page=brainwerk-security-suite&tab=hardening' ),
            );
        }
        return array(
            'id'      => 'critical_modules_on',
            'label'   => __( 'Critical security modules', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => __( 'Brute-force + XML-RPC hardening both active.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    private static function check_2fa_recovery_codes_present() {
        global $wpdb;
        // Find users with 2FA enabled but EMPTY recovery codes — they'd be
        // locked out if they lose their authenticator.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- aggregated multi-meta-key read with no WP_User_Query equivalent; capability-gated admin health check; not cacheable across usermeta writes.
        $rows = $wpdb->get_results(
            "SELECT u.ID, u.user_login
             FROM {$wpdb->users} u
             INNER JOIN {$wpdb->usermeta} m1 ON m1.user_id = u.ID AND m1.meta_key = 'shieldforge_2fa_enabled' AND m1.meta_value = '1'
             LEFT JOIN  {$wpdb->usermeta} m2 ON m2.user_id = u.ID AND m2.meta_key = 'shieldforge_2fa_recovery'
             WHERE m2.meta_value IS NULL OR m2.meta_value = '' OR m2.meta_value = 'a:0:{}'"
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( ! empty( $rows ) ) {
            return array(
                'id'      => '2fa_no_recovery',
                'label'   => __( '2FA users with no recovery codes', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => sprintf(
                    /* translators: 1: count, 2: usernames */
                    _n( '%1$d user (%2$s) has 2FA enabled but no recovery codes — they will be locked out if they lose their authenticator.', '%1$d users (%2$s) have 2FA enabled but no recovery codes — they will be locked out if they lose their authenticator.', count( $rows ), 'brainwerk-security-suite' ),
                    count( $rows ),
                    implode( ', ', wp_list_pluck( $rows, 'user_login' ) )
                ),
                'fix_url' => admin_url( 'admin.php?page=brainwerk-security-suite&tab=twofactor' ),
            );
        }
        return array(
            'id'      => '2fa_recovery_ok',
            'label'   => __( '2FA recovery codes', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => __( 'Every 2FA-enrolled user has recovery codes on file.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    private static function check_admin_2fa_coverage() {
        global $wpdb;
        // Count administrators (per-site capabilities meta search)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- LIKE-based capabilities-meta scan with no WP_User_Query equivalent; capability-gated admin health check; not cacheable across usermeta writes.
        $admins = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT DISTINCT u.ID, u.user_login
                 FROM {$wpdb->users} u
                 INNER JOIN {$wpdb->usermeta} caps
                    ON caps.user_id = u.ID
                    AND caps.meta_key LIKE %s
                    AND caps.meta_value LIKE %s",
                '%capabilities', '%administrator%'
            )
        );
        $unprotected = array();
        foreach ( (array) $admins as $a ) {
            $has_2fa      = (string) get_user_meta( $a->ID, 'shieldforge_2fa_enabled', true );
            $has_webauthn = (array) get_user_meta( $a->ID, 'sf_pro_webauthn_creds', true );
            if ( '1' !== $has_2fa && empty( $has_webauthn ) ) {
                $unprotected[] = $a->user_login;
            }
        }
        if ( ! empty( $unprotected ) ) {
            return array(
                'id'      => 'admins_unprotected',
                'label'   => __( 'Administrators without a second factor', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => sprintf(
                    _n( '%d admin without 2FA / Passkey: %s. Highest-leverage account on the site — should be protected.', '%d admins without 2FA / Passkey: %s. Highest-leverage accounts on the site — should be protected.', count( $unprotected ), 'brainwerk-security-suite' ),
                    count( $unprotected ),
                    implode( ', ', $unprotected )
                ),
                'fix_url' => admin_url( 'users.php' ),
            );
        }
        return array(
            'id'      => 'admins_protected',
            'label'   => __( 'Administrators with a second factor', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => __( 'Every administrator has 2FA or a Passkey enabled.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    private static function check_no_test_fixtures() {
        $needles = array(
            WP_PLUGIN_DIR . '/sf-test-fixture.php',
            WP_PLUGIN_DIR . '/test-fixture.php',
        );
        $found = array();
        foreach ( $needles as $p ) {
            if ( file_exists( $p ) ) {
                $found[] = basename( $p );
            }
        }
        if ( $found ) {
            return array(
                'id'      => 'test_fixtures_present',
                'label'   => __( 'Test fixtures in plugins directory', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_FAIL,
                'message' => sprintf( __( 'Found: %s. Delete before going live.', 'brainwerk-security-suite' ), implode( ', ', $found ) ),
                'fix_url' => null,
            );
        }
        return array(
            'id'      => 'no_test_fixtures',
            'label'   => __( 'Test fixtures', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => __( 'No leftover test files in the plugins directory.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    private static function check_readme_stable_tag() {
        $path = SHIELDFORGE_PATH . 'readme.txt';
        if ( ! file_exists( $path ) ) {
            return array(
                'id'      => 'readme_missing',
                'label'   => __( 'wp.org readme.txt', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_FAIL,
                'message' => __( 'readme.txt is missing — wp.org requires it.', 'brainwerk-security-suite' ),
                'fix_url' => null,
            );
        }
        $content = (string) file_get_contents( $path );
        if ( preg_match( '/^Stable tag:\s*(\S+)/m', $content, $m ) ) {
            $tag = trim( $m[1] );
            if ( defined( 'SHIELDFORGE_VERSION' ) && $tag !== SHIELDFORGE_VERSION ) {
                return array(
                    'id'      => 'readme_stable_tag_mismatch',
                    'label'   => __( 'readme.txt Stable tag', 'brainwerk-security-suite' ),
                    'status'  => self::STATUS_WARN,
                    'message' => sprintf( __( 'Stable tag is %1$s but plugin is %2$s. Bump readme before tagging the wp.org release.', 'brainwerk-security-suite' ), $tag, SHIELDFORGE_VERSION ),
                    'fix_url' => null,
                );
            }
            return array(
                'id'      => 'readme_stable_tag_ok',
                'label'   => __( 'readme.txt Stable tag', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_OK,
                'message' => sprintf( __( 'Matches plugin version (%s).', 'brainwerk-security-suite' ), $tag ),
                'fix_url' => null,
            );
        }
        return array(
            'id'      => 'readme_no_stable_tag',
            'label'   => __( 'readme.txt Stable tag', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_WARN,
            'message' => __( 'No "Stable tag:" line found in readme.txt.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    private static function check_translation_files_present() {
        $langdir = SHIELDFORGE_PATH . 'languages/';
        $required = array( 'shieldforge.pot', 'shieldforge-de_DE.po', 'shieldforge-de_DE.mo' );
        $missing = array();
        foreach ( $required as $f ) {
            if ( ! file_exists( $langdir . $f ) ) {
                $missing[] = $f;
            }
        }
        if ( $missing ) {
            return array(
                'id'      => 'translations_missing',
                'label'   => __( 'Translation files', 'brainwerk-security-suite' ),
                'status'  => self::STATUS_WARN,
                'message' => sprintf( __( 'Missing: %s.', 'brainwerk-security-suite' ), implode( ', ', $missing ) ),
                'fix_url' => null,
            );
        }
        return array(
            'id'      => 'translations_present',
            'label'   => __( 'Translation files', 'brainwerk-security-suite' ),
            'status'  => self::STATUS_OK,
            'message' => __( 'POT + DE PO/MO files all present in languages/.', 'brainwerk-security-suite' ),
            'fix_url' => null,
        );
    }

    /* ==================================================================
     *  Helpers
     * ================================================================== */

    private static function map_severity_to_status( $severity ) {
        $sev = (string) $severity;
        if ( in_array( $sev, array( 'critical', 'high', 'fail' ), true ) ) {
            return self::STATUS_FAIL;
        }
        if ( in_array( $sev, array( 'warn', 'warning', 'medium' ), true ) ) {
            return self::STATUS_WARN;
        }
        return self::STATUS_OK;
    }
}
