<?php
/**
 * Settings facade — reads from network-option (Multisite) or option (single site).
 *
 * Network-wide defaults can be overridden per site if 'allow_site_overrides'
 * is enabled and the site has its own setting in wp_options.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Settings {

    /** Settings cache to avoid repeated DB hits within a request. */
    private static $cache = null;

    public static function all() {
        if ( null !== self::$cache ) {
            return self::$cache;
        }
        $network = is_multisite() ? get_site_option( SHIELDFORGE_OPTIONS_KEY, array() ) : array();
        $site    = get_option( SHIELDFORGE_OPTIONS_KEY, array() );
        $network = is_array( $network ) ? $network : array();
        $site    = is_array( $site ) ? $site : array();

        if ( empty( $network ) ) {
            // No network-wide policy in play → the site option is the whole
            // config (also the single-site case, where $network is always []).
            $merged = $site;
        } elseif ( ! empty( $network['allow_site_overrides'] ) ) {
            // Network admin explicitly opted in to per-site overrides.
            $merged = array_replace( $network, $site );
        } else {
            // Secure default: network policy wins. A subsite may only ADD keys
            // the network doesn't define — it can never weaken a value the
            // network admin set (e.g. re-enable a disabled login URL, turn off
            // the WAF, or self-whitelist an IP) for its own site.
            $merged = array_replace( $site, $network );
        }
        self::$cache = $merged;
        return $merged;
    }

    public static function get( $key, $default = null ) {
        $all = self::all();
        return array_key_exists( $key, $all ) ? $all[ $key ] : $default;
    }

    public static function update( array $changes, $scope = 'auto' ) {
        $current = self::all();
        $next    = array_replace( $current, self::sanitize( $changes ) );

        // Decide the target store. On Multisite the network-wide option must
        // only be written by a network admin — otherwise a plain site admin
        // (manage_options WITHOUT manage_network_options) could rewrite the
        // security policy for EVERY site on the network. Such callers are
        // transparently confined to their own site option instead of being
        // rejected, so legitimate per-site saves keep working.
        $target_network = is_multisite() && ( 'site' !== $scope );
        if ( $target_network && ! current_user_can( 'manage_network_options' ) ) {
            $target_network = false;
        }

        if ( $target_network ) {
            update_site_option( SHIELDFORGE_OPTIONS_KEY, $next );
        } else {
            update_option( SHIELDFORGE_OPTIONS_KEY, $next );
        }
        self::$cache = null;
        return $next;
    }

    /**
     * Per-key sanitization. Unknown keys are dropped.
     */
    public static function sanitize( array $input ) {
        $clean = array();
        foreach ( $input as $key => $value ) {
            switch ( $key ) {
                case 'login_log_enabled':
                case 'bruteforce_enabled':
                case 'bf_user_lockout_enabled':
                case 'probing_track_enabled':
                case 'hardening_disable_xmlrpc':
                case 'hardening_disable_editor':
                case 'hardening_hide_version':
                case 'hardening_block_authors':
                case 'hardening_security_headers':
                case 'hardening_rest_restrict':
                case 'notify_email_enabled':
                case 'digest_enabled':
                case 'privacy_anonymize_ip':
                case 'privacy_pseudonym_users':
                case 'anomaly_enabled':
                case 'scanner_enabled':
                case 'scanner_pattern_enabled':
                case 'twofactor_enabled':
                case 'vulnscan_enabled':
                case 'vulnscan_check_themes':
                case 'vulnscan_check_core':
                case 'audit_enabled':
                case 'audit_track_posts':
                case 'audit_track_users':
                case 'audit_track_options':
                case 'audit_track_updates':
                case 'login_url_enabled':
                case 'honeypot_enabled':
                case 'honeypot_login':
                case 'honeypot_register':
                case 'honeypot_comment':
                case 'hardening_header_xcto':
                case 'hardening_header_xfo':
                case 'hardening_header_referrer':
                case 'hardening_header_permissions':
                case 'hardening_header_hsts':
                case 'hardening_csp_enabled':
                case 'hardening_csp_report_only':
                // v0.12 — neue Module
                case 'pwned_check_enabled':
                case 'headers_enabled':
                case 'headers_x_frame':
                case 'headers_x_content_type':
                case 'headers_referrer_policy':
                case 'headers_permissions_policy':
                case 'headers_hsts':
                case 'headers_hsts_subdomains':
                case 'headers_hsts_preload':
                case 'headers_csp':
                case 'headers_coop':
                case 'headers_corp':
                case 'headers_hide_server':
                case 'captcha_enabled':
                case 'live_traffic_enabled':
                case 'trusted_devices_enabled':
                case 'magic_link_enabled':
                case 'waf_enabled':
                case 'waf_whitelist_admins':
                case 'waf_autoblock_ip':
                // Pfad B (Threat-Intelligence Network) — sensor opt-in.
                // Both default OFF; nothing leaves the site until enabled.
                case 'sensor_enabled':
                case 'sensor_share_with_feed':
                // Intel feed (return path). Rules apply by default; indicator
                // blocking is opt-in so a young feed can't lock out visitors.
                case 'intel_apply_rules':
                case 'intel_block_enabled':
                case 'intel_immunize':
                // Incident Response module (opt-in, default OFF).
                case 'incident_enabled':
                // v0.15 — Repo-Integrität (Cron) + Crawler-Rate-Limiting.
                // Alle default OFF bzw. nur wirksam wenn ratelimit_enabled.
                case 'repo_integrity_enabled':
                case 'ratelimit_enabled':
                case 'ratelimit_verify_bots':
                case 'ratelimit_block_fake_bots':
                // v0.15 — "Secured by" Footer-Badge (default OFF).
                case 'badge_enabled':
                // Site-country auto-detect (default OFF) — see Shieldforge_Geo::site_country().
                case 'sf_site_cc_autodetect':
                    $clean[ $key ] = (bool) $value;
                    break;

                case 'ratelimit_rpm':
                case 'ratelimit_crawler_rpm':
                case 'ratelimit_ban_minutes':
                    $clean[ $key ] = max( 1, (int) $value );
                    break;

                case 'intel_block_threshold':
                    $clean[ $key ] = min( 1.0, max( 0.0, (float) $value ) );
                    break;

                case 'headers_x_frame_value':
                case 'headers_referrer_policy_value':
                case 'headers_permissions_policy_value':
                case 'headers_csp_mode':
                case 'headers_csp_value':
                case 'captcha_provider':
                case 'captcha_site_key':
                case 'captcha_secret_key':
                    $v = (string) $value;
                    $v = str_replace( array( "\r", "\n", "\t" ), ' ', $v );
                    $v = preg_replace( '/[\x00-\x1F\x7F]/', '', $v );
                    $clean[ $key ] = trim( $v );
                    break;

                case 'headers_hsts_max_age':
                case 'waf_paranoia_level':
                    $clean[ $key ] = max( 0, (int) $value );
                    break;

                case 'waf_path_whitelist':
                    $list = is_array( $value ) ? $value : array_map( 'trim', explode( "\n", (string) $value ) );
                    $list = array_filter( array_map( 'sanitize_text_field', $list ) );
                    $clean[ $key ] = array_values( $list );
                    break;

                case 'login_url_slug':
                    // alphanumeric + dash + underscore only — no slashes, no
                    // dots; this becomes part of a URL path segment.
                    $clean[ $key ] = trim( preg_replace( '/[^a-z0-9_-]/i', '', (string) $value ), '/' );
                    break;

                case 'hardening_csp_value':
                    // CSP is a long header value with semicolons, commas,
                    // wildcards. Strip control chars + newlines but keep the
                    // CSP-syntax-relevant punctuation intact.
                    $v = (string) $value;
                    $v = str_replace( array( "\r", "\n", "\t" ), ' ', $v );
                    $v = preg_replace( '/[\x00-\x1F\x7F]/', '', $v );
                    $clean[ $key ] = trim( $v );
                    break;

                case 'vulnscan_endpoint':
                case 'sensor_api_base':
                    $clean[ $key ] = esc_url_raw( (string) $value );
                    break;

                case 'sensor_register_token':
                    // Server tokens are openssl-hex; allow alphanum + a few safe chars.
                    $clean[ $key ] = trim( preg_replace( '/[^A-Za-z0-9._\-]/', '', (string) $value ) );
                    break;

                case 'sf_site_cc_override':
                    // Empty is valid (clears the override); otherwise must be a bare 2-letter code.
                    $v = strtoupper( trim( preg_replace( '/[^A-Za-z]/', '', (string) $value ) ) );
                    $clean[ $key ] = ( 2 === strlen( $v ) ) ? $v : '';
                    break;

                case 'twofactor_force_roles':
                    $list = is_array( $value ) ? $value : array();
                    $list = array_filter( array_map( 'sanitize_key', $list ) );
                    $clean[ $key ] = array_values( array_unique( $list ) );
                    break;

                case 'bruteforce_threshold':
                case 'bruteforce_window_min':
                case 'bruteforce_lockout_min':
                case 'bf_user_lockout_threshold':
                case 'bf_user_lockout_window_min':
                case 'bf_user_lockout_minutes':
                case 'probing_alert_threshold':
                case 'log_retention_days':
                case 'anomaly_block_score':
                case 'anomaly_alert_score':
                case 'scanner_max_file_kb':
                    $clean[ $key ] = max( 1, (int) $value );
                    break;

                case 'scanner_skip_dirs':
                case 'scanner_extensions':
                    $list = is_array( $value ) ? $value : array_map( 'trim', explode( "\n", (string) $value ) );
                    $list = array_filter( array_map( 'sanitize_text_field', $list ) );
                    $clean[ $key ] = array_values( $list );
                    break;

                case 'notify_email_to':
                    $email = sanitize_email( $value );
                    if ( $email ) {
                        $clean[ $key ] = $email;
                    }
                    break;

                case 'bruteforce_whitelist':
                    $list = is_array( $value ) ? $value : array_map( 'trim', explode( "\n", (string) $value ) );
                    $list = array_filter( array_map( 'sanitize_text_field', $list ) );
                    $clean[ $key ] = array_values( $list );
                    break;

                case 'notify_quiet_hours':
                    if ( is_array( $value ) ) {
                        $start = isset( $value['start'] ) ? preg_replace( '/[^0-9:]/', '', $value['start'] ) : '22:00';
                        $end   = isset( $value['end'] ) ? preg_replace( '/[^0-9:]/', '', $value['end'] ) : '07:00';
                        $clean[ $key ] = array( 'start' => $start, 'end' => $end );
                    }
                    break;

                case 'plugin_version':
                    $clean[ $key ] = sanitize_text_field( (string) $value );
                    break;

                case 'license_key':
                    // Legacy field kept for backward compat with stored settings.
                    // Pro activation now lives in the Pro plugin via Freemius.
                    $clean[ $key ] = sanitize_text_field( (string) $value );
                    break;

                // Unknown keys are ignored.
            }
        }
        return $clean;
    }

    public static function flush_cache() {
        self::$cache = null;
    }
}
