<?php
/**
 * Configuration health check — surfaces deployment-level issues that would
 * silently neuter (or worse, weaponize) the plugin.
 *
 * The flagship check: detects when the server runs behind a non-transparent
 * proxy / multiplexer (sslh, nginx-stream, Apache reverse-proxy, Cloudflare
 * Tunnel) so that REMOTE_ADDR is always 127.0.0.1. In that mode IP-based
 * brute-force protection is not just useless — it lets one attacker
 * lock-out every user by burning the shared 127.0.0.1 counter. We render
 * a hard error notice + per-tab callout so the admin cannot miss it.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Healthcheck {

    const TRANSIENT_KEY = 'shieldforge_healthcheck_v1';
    const TRANSIENT_TTL = 5 * MINUTE_IN_SECONDS;

    const DISMISS_META_KEY = 'shieldforge_dismissed_healthchecks';

    public static function register( Shieldforge_Loader $loader ) {
        $loader->add_action( 'admin_notices', __CLASS__, 'render_admin_notice' );
        $loader->add_action( 'network_admin_notices', __CLASS__, 'render_admin_notice' );
        $loader->add_action( 'admin_init', __CLASS__, 'handle_dismiss' );
    }

    /**
     * Persist a per-user dismissal of a non-critical finding. Critical
     * findings (active security danger) are never dismissible. A dismissed
     * finding stays hidden until its underlying condition clears and recurs.
     */
    public static function handle_dismiss() {
        if ( empty( $_GET['shieldforge_dismiss_hc'] ) ) {
            return;
        }
        $id = sanitize_key( wp_unslash( $_GET['shieldforge_dismiss_hc'] ) );
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            return;
        }
        check_admin_referer( 'shieldforge_dismiss_hc_' . $id );

        $user_id   = get_current_user_id();
        $dismissed = get_user_meta( $user_id, self::DISMISS_META_KEY, true );
        $dismissed = is_array( $dismissed ) ? $dismissed : array();
        if ( ! in_array( $id, $dismissed, true ) ) {
            $dismissed[] = $id;
            update_user_meta( $user_id, self::DISMISS_META_KEY, $dismissed );
        }
        wp_safe_redirect( remove_query_arg( array( 'shieldforge_dismiss_hc', '_wpnonce' ) ) );
        exit;
    }

    private static function is_dismissed( $id ) {
        $dismissed = get_user_meta( get_current_user_id(), self::DISMISS_META_KEY, true );
        return is_array( $dismissed ) && in_array( $id, $dismissed, true );
    }

    /**
     * Run all checks and return findings. Cached for TRANSIENT_TTL so we
     * don't re-evaluate on every admin page load.
     *
     * Each finding is array(
     *   'id' => stable_id,
     *   'severity' => 'critical' | 'warning' | 'info',
     *   'title' => short, escaped string,
     *   'body' => longer explanation, escaped HTML allowed,
     *   'fix'  => one-line how-to-fix, escaped HTML allowed,
     * ).
     */
    public static function run( $force = false ) {
        if ( ! $force ) {
            $cached = get_transient( self::TRANSIENT_KEY );
            if ( is_array( $cached ) ) {
                return $cached;
            }
        }
        $findings = array();

        // The proxy-IP check inspects the live request's REMOTE_ADDR. Under
        // WP-CLI/cron there is no real client, so REMOTE_ADDR is always
        // 127.0.0.1 — running the check here would produce a false positive
        // and (worse) poison the transient for the next 5 minutes of browser
        // hits. Skip when there is no real HTTP client.
        $is_cli_context = self::is_cli_context();
        if ( ! $is_cli_context ) {
            $finding = self::check_proxy_ip_masking();
            if ( $finding ) {
                $findings[] = $finding;
            }
        }

        $finding = self::check_anonymize_ip_off();
        if ( $finding ) {
            $findings[] = $finding;
        }

        $finding = self::check_bruteforce_disabled();
        if ( $finding ) {
            $findings[] = $finding;
        }

        // Don't cache CLI-context results — they're missing the proxy-IP
        // check and would otherwise be served to browser requests.
        if ( ! $is_cli_context ) {
            set_transient( self::TRANSIENT_KEY, $findings, self::TRANSIENT_TTL );
        }
        return $findings;
    }

    /**
     * True when the request has no real HTTP client (WP-CLI, cron, REST
     * worker started by a CLI bootstrap, etc.). We err on the safe side and
     * skip the proxy-IP check whenever REMOTE_ADDR could be synthetic.
     */
    private static function is_cli_context() {
        if ( defined( 'WP_CLI' ) && WP_CLI ) {
            return true;
        }
        if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
            return true;
        }
        if ( 'cli' === PHP_SAPI ) {
            return true;
        }
        return false;
    }

    public static function flush_cache() {
        delete_transient( self::TRANSIENT_KEY );
    }

    /**
     * Detect REMOTE_ADDR=127.0.0.1 (or ::1) without trusted-proxy config.
     * That's the sslh/cloudflared/proxy-without-PROXY-protocol case.
     */
    public static function check_proxy_ip_masking() {
        $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
        $is_localhost = in_array( $remote, array( '127.0.0.1', '::1' ), true );
        $has_trusted  = defined( 'SHIELDFORGE_TRUSTED_PROXIES' ) && ! empty( SHIELDFORGE_TRUSTED_PROXIES );

        if ( ! $is_localhost ) {
            return null;
        }
        if ( $has_trusted ) {
            // Trusted proxy is configured AND request did come from it — that's the
            // expected setup, real IP is read from forwarded headers.
            $real = Shieldforge_Utils::get_real_ip();
            if ( $real && $real !== $remote ) {
                return null; // working as intended
            }
        }

        return array(
            'id'       => 'proxy_ip_masking',
            'severity' => 'critical',
            'title'    => __( 'Server sees every visitor as 127.0.0.1 — IP-based protection is broken.', 'brainwerk-security-suite' ),
            'body'     => __( 'Your web server is running behind a proxy or multiplexer (typical: sslh, Cloudflare Tunnel, nginx-stream, Apache mod_proxy) that does not forward the original client IP. Brainwerk Security Suite therefore counts every brute-force attempt against the same bucket — five failed logins from any visitor will lock out 127.0.0.1, which silently blocks every user including yourself. Probing detection, anomaly scoring and threat intel are equally affected.', 'brainwerk-security-suite' ),
            'fix'      => __( 'Either (a) configure your proxy to forward the real client IP via PROXY-protocol or X-Forwarded-For header AND set <code>define(\'SHIELDFORGE_TRUSTED_PROXIES\', array(\'127.0.0.1\'));</code> in wp-config.php, or (b) keep brute-force protection disabled until the proxy is fixed.', 'brainwerk-security-suite' ),
        );
    }

    public static function check_anonymize_ip_off() {
        if ( Shieldforge_Settings::get( 'privacy_anonymize_ip', true ) ) {
            return null;
        }
        return array(
            'id'       => 'anonymize_ip_off',
            'severity' => 'warning',
            'title'    => __( 'Privacy: IP anonymization is OFF.', 'brainwerk-security-suite' ),
            'body'     => __( 'Full IP addresses are stored in the Brainwerk Security Suite log. In the EU this is personal data under GDPR and requires either a documented lawful basis or anonymization. Brainwerk Security Suite ships with anonymization on by default for this reason.', 'brainwerk-security-suite' ),
            'fix'      => __( 'Enable "Anonymize IP addresses" in the Privacy / GDPR tab unless you have a documented legal basis to keep full IPs.', 'brainwerk-security-suite' ),
        );
    }

    public static function check_bruteforce_disabled() {
        if ( Shieldforge_Settings::get( 'bruteforce_enabled', true ) ) {
            return null;
        }
        return array(
            'id'       => 'bruteforce_disabled',
            'severity' => 'warning',
            'title'    => __( 'Brute-force protection is currently disabled.', 'brainwerk-security-suite' ),
            'body'     => __( 'Failed login events are still being logged for visibility, but no IP will be locked out after repeated failures. Re-enable this only after verifying the server passes the IP-masking check above.', 'brainwerk-security-suite' ),
            'fix'      => __( 'Re-enable in the Login & Auth tab.', 'brainwerk-security-suite' ),
        );
    }

    public static function render_admin_notice() {
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            return;
        }
        // Avoid spamming on the WP-CLI / AJAX / heartbeat noise.
        if ( wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
            return;
        }
        $findings = self::run();
        if ( empty( $findings ) ) {
            return;
        }
        foreach ( $findings as $f ) {
            // Critical findings signal an active security danger (e.g. IP-based
            // protection is broken and can lock out every user) and are never
            // dismissible. Warnings/info are dismissible per-user.
            $is_critical = ( 'critical' === $f['severity'] );

            if ( ! $is_critical && self::is_dismissed( $f['id'] ) ) {
                continue;
            }

            $class = 'notice';
            switch ( $f['severity'] ) {
                case 'critical': $class .= ' notice-error'; break;
                case 'warning':  $class .= ' notice-warning'; break;
                default:         $class .= ' notice-info'; break;
            }
            ?>
            <div class="<?php echo esc_attr( $class ); ?>">
                <p><strong>Brainwerk Security Suite:</strong> <?php echo esc_html( $f['title'] ); ?></p>
                <p><?php echo wp_kses_post( $f['body'] ); ?></p>
                <p><em><?php echo wp_kses_post( $f['fix'] ); ?></em></p>
                <?php
                if ( ! $is_critical ) {
                    $dismiss_url = wp_nonce_url(
                        add_query_arg( 'shieldforge_dismiss_hc', rawurlencode( $f['id'] ) ),
                        'shieldforge_dismiss_hc_' . $f['id']
                    );
                    ?>
                    <p><a href="<?php echo esc_url( $dismiss_url ); ?>"><?php esc_html_e( 'Dismiss this notice', 'brainwerk-security-suite' ); ?></a></p>
                    <?php
                }
                ?>
            </div>
            <?php
        }
    }
}
