<?php
/**
 * User-activity audit trail.
 *
 * Hooks into WordPress lifecycle events that matter for compliance + incident
 * response (DSGVO Art. 32: "Maßnahmen zur Sicherheit der Verarbeitung"):
 *   - posts:   post_updated, transition_post_status, wp_trash_post, delete_post
 *   - users:   profile_update, delete_user, wp_logout
 *   - updates: upgrader_process_complete (plugin / theme / core)
 *   - options: update_site_option / update_option for security-sensitive keys
 *
 * Plugin/theme activations + theme switch + user_register + role_changed are
 * already covered by Shieldforge_Hardening, kept there to avoid churn.
 *
 * Each tracked group is gated by its own setting so admins can dial down to
 * only what they care about (and stay under the log_retention_days budget).
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_AuditLog {

    /** Post types we never log — too noisy or never user-facing. */
    private static $skip_post_types = array(
        'revision',
        'auto-draft',
        'nav_menu_item',
        'customize_changeset',
        'oembed_cache',
        'user_request',
        'wp_block',
        'wp_template',
        'wp_template_part',
        'wp_global_styles',
        'wp_navigation',
    );

    /**
     * Site-options whose changes we log even though the option-write
     * happens hundreds of times per request normally. Whitelist keeps the
     * log signal-rich (everything else is filtered out).
     *
     * Severity per key — sensitive ones (URL/email/registration policy)
     * are HIGH; cosmetic ones (blog name, language) are INFO.
     */
    private static $tracked_options = array(
        // sensitive — phishing / takeover vectors
        'siteurl'             => Shieldforge_Logger::SEV_HIGH,
        'home'                => Shieldforge_Logger::SEV_HIGH,
        'admin_email'         => Shieldforge_Logger::SEV_HIGH,
        'new_admin_email'     => Shieldforge_Logger::SEV_HIGH,
        'users_can_register'  => Shieldforge_Logger::SEV_HIGH,
        'default_role'        => Shieldforge_Logger::SEV_HIGH,
        'blog_public'         => Shieldforge_Logger::SEV_WARN,
        // cosmetic but still nice to have a paper-trail for
        'blogname'            => Shieldforge_Logger::SEV_INFO,
        'blogdescription'     => Shieldforge_Logger::SEV_INFO,
        'WPLANG'              => Shieldforge_Logger::SEV_INFO,
        // multisite-network-only
        'registration'        => Shieldforge_Logger::SEV_HIGH,
        'add_new_users'       => Shieldforge_Logger::SEV_WARN,
        'site_admins'         => Shieldforge_Logger::SEV_CRITICAL,
    );

    /* ==================================================================
     *  Registration
     * ================================================================== */

    public static function register( Shieldforge_Loader $loader ) {
        if ( ! self::is_enabled() ) {
            return;
        }

        // Auth
        $loader->add_action( 'wp_logout', __CLASS__, 'on_logout', 10, 1 );

        // Posts
        $loader->add_action( 'post_updated',          __CLASS__, 'on_post_updated', 10, 3 );
        $loader->add_action( 'wp_trash_post',         __CLASS__, 'on_post_trashed', 10, 1 );
        $loader->add_action( 'before_delete_post',    __CLASS__, 'on_post_deleted', 10, 1 );

        // Users
        $loader->add_action( 'profile_update',        __CLASS__, 'on_profile_update', 10, 2 );
        $loader->add_action( 'deleted_user',          __CLASS__, 'on_user_deleted', 10, 2 );
        // wpmu_delete_user fires for network-wide user deletion on Multisite.
        $loader->add_action( 'wpmu_delete_user',      __CLASS__, 'on_user_deleted_ms', 10, 1 );

        // Updates (plugin / theme / core via WP updater)
        $loader->add_action( 'upgrader_process_complete', __CLASS__, 'on_upgrader_complete', 10, 2 );

        // Options — both single-site (update_option) and Multisite-network (update_site_option).
        // We hook a generic listener that filters by key against $tracked_options.
        $loader->add_action( 'updated_option',        __CLASS__, 'on_option_updated', 10, 3 );
        // WP 5.5+: ($option, $value, $old_value, $network_id) — 4 args.
        // Pre-5.5 only sent ($option, $value); we stay defensive in the
        // handler signature (default-null) so older sites don't fatal.
        $loader->add_action( 'update_site_option',    __CLASS__, 'on_site_option_updated', 10, 4 );
    }

    public static function is_enabled() {
        return (bool) Shieldforge_Settings::get( 'audit_enabled', true );
    }

    /* ==================================================================
     *  Auth
     * ================================================================== */

    public static function on_logout( $user_id ) {
        $user = get_user_by( 'id', (int) $user_id );
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_LOGOUT, array(
            'severity' => Shieldforge_Logger::SEV_INFO,
            'user_id'  => (int) $user_id,
            'username' => $user ? Shieldforge_Utils::store_username( $user->user_login ) : '',
            'context'  => array(),
        ) );
    }

    /* ==================================================================
     *  Posts
     * ================================================================== */

    public static function on_post_updated( $post_id, $post_after, $post_before ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_posts', true ) ) {
            return;
        }
        if ( ! is_object( $post_after ) || ! self::is_loggable_post( $post_after ) ) {
            return;
        }
        // Skip the implicit "auto-draft → draft" first-save burst — too noisy.
        if ( is_object( $post_before ) && 'auto-draft' === $post_before->post_status ) {
            return;
        }
        $changes = self::diff_post( $post_before, $post_after );
        // Status transitions get their own severity bump; pure title/content
        // edits are INFO.
        $sev = Shieldforge_Logger::SEV_INFO;
        if ( isset( $changes['post_status'] ) ) {
            $to = $changes['post_status']['to'];
            if ( in_array( $to, array( 'publish' ), true ) ) {
                $sev = Shieldforge_Logger::SEV_WARN;
            }
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_POST_UPDATED, array(
            'severity' => $sev,
            'context'  => array(
                'post_id'    => (int) $post_id,
                'post_type'  => $post_after->post_type,
                'post_title' => self::trim_title( $post_after->post_title ),
                'changes'    => $changes,
            ),
        ) );
    }

    public static function on_post_trashed( $post_id ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_posts', true ) ) {
            return;
        }
        $post = get_post( (int) $post_id );
        if ( ! $post || ! self::is_loggable_post( $post ) ) {
            return;
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_POST_TRASHED, array(
            'severity' => Shieldforge_Logger::SEV_WARN,
            'context'  => array(
                'post_id'    => (int) $post_id,
                'post_type'  => $post->post_type,
                'post_title' => self::trim_title( $post->post_title ),
            ),
        ) );
    }

    public static function on_post_deleted( $post_id ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_posts', true ) ) {
            return;
        }
        $post = get_post( (int) $post_id );
        if ( ! $post || ! self::is_loggable_post( $post ) ) {
            return;
        }
        // Permanent delete is irreversible — bump severity higher than trash.
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_POST_DELETED, array(
            'severity' => Shieldforge_Logger::SEV_HIGH,
            'context'  => array(
                'post_id'    => (int) $post_id,
                'post_type'  => $post->post_type,
                'post_title' => self::trim_title( $post->post_title ),
            ),
        ) );
    }

    private static function is_loggable_post( $post ) {
        if ( ! is_object( $post ) || empty( $post->post_type ) ) {
            return false;
        }
        if ( in_array( $post->post_type, self::$skip_post_types, true ) ) {
            return false;
        }
        // Allow callers to extend the skip-list.
        $extra_skip = (array) apply_filters( 'shieldforge_audit_skip_post_types', array() );
        if ( in_array( $post->post_type, $extra_skip, true ) ) {
            return false;
        }
        return true;
    }

    /**
     * Compute a tight, log-table-friendly diff of the fields that matter
     * for an audit trail: title, status, author, slug, password-protect,
     * comment-status. We deliberately do NOT log content-body diffs (huge,
     * privacy-sensitive, useless for incident response).
     */
    private static function diff_post( $before, $after ) {
        $watch = array( 'post_status', 'post_title', 'post_name', 'post_author', 'post_password', 'comment_status', 'ping_status' );
        $diff = array();
        foreach ( $watch as $f ) {
            $b = isset( $before->$f ) ? $before->$f : null;
            $a = isset( $after->$f )  ? $after->$f  : null;
            if ( $b !== $a ) {
                if ( 'post_password' === $f ) {
                    // Don't log the password value itself; just whether it changed.
                    $diff[ $f ] = array( 'from' => $b ? '[set]' : '[empty]', 'to' => $a ? '[set]' : '[empty]' );
                } else {
                    $diff[ $f ] = array( 'from' => self::trim_title( (string) $b ), 'to' => self::trim_title( (string) $a ) );
                }
            }
        }
        return $diff;
    }

    private static function trim_title( $s ) {
        $s = (string) $s;
        return mb_strlen( $s ) > 80 ? mb_substr( $s, 0, 77 ) . '...' : $s;
    }

    /* ==================================================================
     *  Users
     * ================================================================== */

    public static function on_profile_update( $user_id, $old_user_data ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_users', true ) ) {
            return;
        }
        $user = get_user_by( 'id', (int) $user_id );
        if ( ! $user ) {
            return;
        }
        $changes = self::diff_user( $old_user_data, $user );
        // No semantic change (e.g. WP fires this on lots of meta tweaks) → skip.
        if ( empty( $changes ) ) {
            return;
        }
        // Bump severity if email or password changed (account-takeover signal).
        $sev = Shieldforge_Logger::SEV_INFO;
        if ( isset( $changes['user_email'] ) || isset( $changes['user_pass'] ) ) {
            $sev = Shieldforge_Logger::SEV_WARN;
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_USER_PROFILE_UPDATED, array(
            'severity' => $sev,
            'user_id'  => (int) $user_id,
            'username' => Shieldforge_Utils::store_username( $user->user_login ),
            'context'  => array(
                'edited_by' => (int) get_current_user_id(),
                'changes'   => $changes,
            ),
        ) );
    }

    /**
     * Per-site user deletion (deleted_user fires AFTER the row is gone, but
     * receives the user object as second arg in WP 5.5+).
     */
    public static function on_user_deleted( $user_id, $reassign = null ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_users', true ) ) {
            return;
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_USER_DELETED, array(
            'severity' => Shieldforge_Logger::SEV_HIGH,
            'user_id'  => (int) $user_id,
            'context'  => array(
                'deleted_by' => (int) get_current_user_id(),
                'reassign_to' => $reassign ? (int) $reassign : null,
            ),
        ) );
    }

    public static function on_user_deleted_ms( $user_id ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_users', true ) ) {
            return;
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_USER_DELETED, array(
            'severity' => Shieldforge_Logger::SEV_CRITICAL,
            'user_id'  => (int) $user_id,
            'context'  => array(
                'deleted_by' => (int) get_current_user_id(),
                'scope'      => 'network',
            ),
        ) );
    }

    private static function diff_user( $old, $new ) {
        $watch = array( 'user_email', 'user_login', 'display_name', 'user_url', 'user_pass' );
        $diff = array();
        foreach ( $watch as $f ) {
            $b = isset( $old->data->$f ) ? $old->data->$f : ( isset( $old->$f ) ? $old->$f : null );
            $a = isset( $new->data->$f ) ? $new->data->$f : ( isset( $new->$f ) ? $new->$f : null );
            if ( $b !== $a ) {
                if ( 'user_pass' === $f ) {
                    // Hash compare only — never expose the hash itself in logs.
                    $diff[ $f ] = array( 'from' => '[changed]', 'to' => '[changed]' );
                } else {
                    $diff[ $f ] = array( 'from' => self::trim_title( (string) $b ), 'to' => self::trim_title( (string) $a ) );
                }
            }
        }
        return $diff;
    }

    /* ==================================================================
     *  Updates (plugin / theme / core)
     * ================================================================== */

    public static function on_upgrader_complete( $upgrader, $hook_extra ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_updates', true ) ) {
            return;
        }
        if ( ! is_array( $hook_extra ) || empty( $hook_extra['action'] ) ) {
            return;
        }
        // We only care about updates here. Installs/translation-updates are
        // covered by other events (plugin_activated etc.) or out-of-scope.
        if ( 'update' !== $hook_extra['action'] ) {
            return;
        }
        $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
        switch ( $type ) {
            case 'plugin':
                $items = isset( $hook_extra['plugins'] ) ? (array) $hook_extra['plugins'] : array();
                foreach ( $items as $file ) {
                    Shieldforge_Logger::log( Shieldforge_Logger::EVT_PLUGIN_UPDATED, array(
                        'severity' => Shieldforge_Logger::SEV_WARN,
                        'context'  => array(
                            'plugin'     => self::trim_title( (string) $file ),
                            'updated_by' => (int) get_current_user_id(),
                        ),
                    ) );
                }
                break;
            case 'theme':
                $items = isset( $hook_extra['themes'] ) ? (array) $hook_extra['themes'] : array();
                foreach ( $items as $stylesheet ) {
                    Shieldforge_Logger::log( Shieldforge_Logger::EVT_THEME_UPDATED, array(
                        'severity' => Shieldforge_Logger::SEV_WARN,
                        'context'  => array(
                            'theme'      => self::trim_title( (string) $stylesheet ),
                            'updated_by' => (int) get_current_user_id(),
                        ),
                    ) );
                }
                break;
            case 'core':
                Shieldforge_Logger::log( Shieldforge_Logger::EVT_CORE_UPDATED, array(
                    'severity' => Shieldforge_Logger::SEV_HIGH,
                    'context'  => array(
                        'new_version' => get_bloginfo( 'version' ),
                        'updated_by'  => (int) get_current_user_id(),
                    ),
                ) );
                break;
        }
    }

    /* ==================================================================
     *  Options (single-site + Multisite-network-options)
     * ================================================================== */

    public static function on_option_updated( $option, $old_value, $new_value ) {
        self::log_option_change( $option, $old_value, $new_value, 'site' );
    }

    public static function on_site_option_updated( $option, $value, $old_value = null, $network_id = null ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_options', true ) ) {
            return;
        }
        if ( ! isset( self::$tracked_options[ $option ] ) ) {
            return;
        }
        // Pre-WP-5.5 doesn't pass old_value — fall back to a fresh read.
        // Doesn't matter much since this fires AFTER the write; the read
        // would return the new value, breaking diff. We accept the false
        // negative there — modern WP (>= 5.5) gets it right.
        if ( null === $old_value ) {
            $old_value = $value; // no-op; will short-circuit in log_option_change
        }
        self::log_option_change( $option, $old_value, $value, 'network' );
    }

    private static function log_option_change( $option, $old_value, $new_value, $scope ) {
        if ( ! Shieldforge_Settings::get( 'audit_track_options', true ) ) {
            return;
        }
        if ( ! isset( self::$tracked_options[ $option ] ) ) {
            return;
        }
        // No-op write — skip.
        if ( $old_value === $new_value ) {
            return;
        }
        $sev = (int) self::$tracked_options[ $option ];
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_OPTION_CHANGED, array(
            'severity' => $sev,
            'context'  => array(
                'option'  => $option,
                'scope'   => $scope,
                'from'    => self::redact_value( $option, $old_value ),
                'to'      => self::redact_value( $option, $new_value ),
                'changed_by' => (int) get_current_user_id(),
            ),
        ) );
    }

    /**
     * Trim/normalize the option value for logging. Some options (like
     * site_admins on Multisite) are arrays — we count + sample. Strings
     * get truncated to keep the context blob small.
     */
    private static function redact_value( $option, $value ) {
        if ( is_array( $value ) ) {
            return array(
                'count'  => count( $value ),
                'sample' => array_slice( array_values( $value ), 0, 5 ),
            );
        }
        if ( is_scalar( $value ) ) {
            $s = (string) $value;
            return mb_strlen( $s ) > 200 ? mb_substr( $s, 0, 197 ) . '...' : $s;
        }
        return '[non-scalar]';
    }

    /* ==================================================================
     *  Read API for the admin UI
     * ================================================================== */

    /**
     * Return only audit-trail event types — used by the Audit tab to
     * filter the shared shieldforge_log table down to the audit slice.
     */
    public static function audit_event_types() {
        return array(
            Shieldforge_Logger::EVT_LOGOUT,
            Shieldforge_Logger::EVT_POST_UPDATED,
            Shieldforge_Logger::EVT_POST_TRASHED,
            Shieldforge_Logger::EVT_POST_DELETED,
            Shieldforge_Logger::EVT_USER_REGISTERED,
            Shieldforge_Logger::EVT_USER_DELETED,
            Shieldforge_Logger::EVT_USER_PROFILE_UPDATED,
            Shieldforge_Logger::EVT_ROLE_CHANGED,
            Shieldforge_Logger::EVT_ADMIN_CREATED,
            Shieldforge_Logger::EVT_PLUGIN_ACTIVATED,
            Shieldforge_Logger::EVT_PLUGIN_DEACTIV,
            Shieldforge_Logger::EVT_PLUGIN_UPDATED,
            Shieldforge_Logger::EVT_THEME_SWITCHED,
            Shieldforge_Logger::EVT_THEME_UPDATED,
            Shieldforge_Logger::EVT_CORE_UPDATED,
            Shieldforge_Logger::EVT_OPTION_CHANGED,
        );
    }

    public static function recent_audit( $limit = 100, $offset = 0, $type_filter = '' ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );

        $types = self::audit_event_types();
        if ( $type_filter && in_array( $type_filter, $types, true ) ) {
            $types = array( $type_filter );
        }
        $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );

        $params = array_merge( $types, array( (int) $limit, (int) $offset ) );
        $sql    = "SELECT * FROM {$table} WHERE event_type IN ({$placeholders}) ORDER BY id DESC LIMIT %d OFFSET %d";

        // phpcs:ignore WordPress.DB.PreparedSQL, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table; direct live read by design; not cacheable.
        $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
        foreach ( (array) $rows as $r ) {
            $r->ctx = json_decode( (string) $r->context, true );
            if ( ! is_array( $r->ctx ) ) {
                $r->ctx = array();
            }
        }
        return (array) $rows;
    }

    public static function audit_summary( $hours = 168 ) {
        global $wpdb;
        $table  = Shieldforge_Utils::table( 'log' );
        $since  = gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS * (int) $hours );
        $types  = self::audit_event_types();
        $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );

        $params = array_merge( array( $since ), $types );
        $sql    = "SELECT event_type, COUNT(*) AS n FROM {$table} WHERE created_at >= %s AND event_type IN ({$placeholders}) GROUP BY event_type ORDER BY n DESC";

        // phpcs:ignore WordPress.DB.PreparedSQL, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table; direct live read by design; not cacheable.
        return (array) $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
    }
}
