<?php
/**
 * CQtrAdminUsersMonitor
 *
 * Monitors WordPress admin users and tracks changes:
 * - Lists current admin users
 * - Stores snapshots in database table
 * - Compares with previous snapshot
 * - Raises alarms when admin users are added or removed
 * - Provides scheduled checking via wp-cron
 *
 * Usage (e.g. in your plugin bootstrap):
 *   add_action('plugins_loaded', function () {
 *       CQtrAdminUsersMonitor::init();
 *   });
 */

require_once('qtrAlarmsManager.php');
require_once('qtrAlarmTypes.php');
require_once('qtrLogger.php');

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

class CQtrAdminUsersMonitor
{
    const TABLE_SUFFIX = 'qtr_admin_users_snapshots';
    const CRON_HOOK = 'qtr_check_admin_users';
    const CRON_INTERVAL = 'qtr_1min'; // custom 1-minute interval
    const OPTION_LAST_CHECK = 'qtr_admin_users_last_check';
    const DEBOUNCE_KEY = 'qtr_admin_check_debounce';

    /** @var bool */
    private static $initialized = false;
    
    /** @var array Tracks which hooks have triggered a check in this request */
    private static $hooks_checked = array();

    /**
     * Get full table name.
     */
    public static function table_name(): string
    {
        global $wpdb;
        return $wpdb->prefix . self::TABLE_SUFFIX;
    }

    /**
     * Initialize hooks and schedule cron (idempotent).
     */
    public static function init(): void
    {
        if (self::$initialized) {
            return;
        }
        self::$initialized = true;

        // Register custom cron schedule (1 minutes)
        add_filter('cron_schedules', [__CLASS__, 'add_custom_cron_schedule']);

        // Create table on init
        self::install();

        // Create baseline snapshot if none exists
        // This ensures we have a snapshot to compare against when first change occurs
        $existing = self::get_latest_snapshot();
        if (empty($existing)) {
            self::create_snapshot();
        }

        // Schedule cron check
        if (!wp_next_scheduled(self::CRON_HOOK)) {
            wp_schedule_event(time(), self::CRON_INTERVAL, self::CRON_HOOK);
        }

        // Hook to cron action
        add_action(self::CRON_HOOK, [__CLASS__, 'check_admin_users']);

        // Check immediately on admin_init for detection (no throttling)
        add_action('admin_init', [__CLASS__, 'check_admin_users']);

        // Hook into WordPress user creation/deletion for immediate alarm
        // Use priority 20 to allow WordPress to finish role assignment first
        add_action('user_register', [__CLASS__, 'on_user_added'], 20, 1);
        add_action('set_user_role', [__CLASS__, 'on_user_role_changed'], 10, 3); // Fires when role is assigned
        add_action('delete_user', [__CLASS__, 'on_delete_user'], 10, 1);  // Fires BEFORE deletion
        add_action('deleted_user', [__CLASS__, 'on_user_removed'], 10, 1); // Fires AFTER deletion
    }

    /**
     * Install database table.
     */
    public static function install(): void
    {
        global $wpdb;
        $table = self::table_name();
        $charset_collate = $wpdb->get_charset_collate();

        // Check if table already exists
        $table_exists = $wpdb->get_var(
            $wpdb->prepare("SHOW TABLES LIKE %s", $table)
        ) === $table;

        if (!$table_exists) {
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';

            $sql = "CREATE TABLE {$table} (
                id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
                snapshot_at DATETIME NOT NULL,
                user_id BIGINT(20) UNSIGNED NOT NULL,
                user_login VARCHAR(60) NOT NULL,
                user_email VARCHAR(100) NOT NULL,
                PRIMARY KEY  (id),
                KEY snapshot_at (snapshot_at),
                KEY user_id (user_id),
                UNIQUE KEY snapshot_user (snapshot_at, user_id)
            ) {$charset_collate};";

            dbDelta($sql);
        }
    }

    /**
     * Register custom 10-minute cron schedule.
     *
     * @param array $schedules Existing cron schedules
     * @return array Updated schedules
     */
    public static function add_custom_cron_schedule(array $schedules): array
    {
        if (!isset($schedules[self::CRON_INTERVAL])) {
            $schedules[self::CRON_INTERVAL] = [
                'interval' => 60, // 1 minute in seconds
                'display'  => __('Every Minute'),
            ];
        }
        return $schedules;
    }

    /**
     * Get all current admin users.
     *
     * @return array Array of WP_User objects with administrator role
     */
    public static function get_current_admin_users(): array
    {
        $admin_users = get_users([
            'role' => 'administrator',
        ]);

        return is_array($admin_users) ? $admin_users : [];
    }

    /**
     * Create a snapshot of current admin users if it differs from the latest snapshot.
     * Compares current admin state with the most recent snapshot.
     * If they differ (users added or removed), creates new snapshot and returns both old and new snapshots.
     * Otherwise returns NULL without creating a new snapshot.
     *
     * @return array|null Array with ['old' => previous_snapshot, 'new' => new_snapshot] if changed, NULL if no changes
     */
    public static function create_snapshot(): ?array
    {
        global $wpdb;
        
        $table = self::table_name();
        $current_admins = self::get_current_admin_users();
        
        // Get the latest snapshot to compare against (this will be the "old" snapshot)
        $old_snapshot = self::get_latest_snapshot();
        
        // Convert current admins to comparable format
        $current_state = [];
        foreach ($current_admins as $user) {
            if ($user instanceof WP_User) {
                $current_state[] = [
                    'user_id'    => $user->ID,
                    'user_login' => $user->user_login,
                    'user_email' => $user->user_email,
                ];
            }
        }
        
        // Sort both by user_id for consistent comparison
        usort($current_state, function($a, $b) {
            return $a['user_id'] <=> $b['user_id'];
        });
        usort($old_snapshot, function($a, $b) {
            return $a['user_id'] <=> $b['user_id'];
        });
        
        // Log the states being compared
        $logger = new CQtrLogger();        
        // Compare: if current state matches latest snapshot, no changes
        if ($current_state === $old_snapshot) {
            // No changes detected
            return null;
        }
        
        $logger->Info("Admin users state changed, creating new snapshot");
        $logger->Info("Current state: " . json_encode($current_state));
        $logger->Info("Last snapshot: " . json_encode($old_snapshot));

        // State has changed - create new snapshot
        $now = current_time('mysql');
        $inserted = 0;
        
        foreach ($current_state as $user) {
            $result = $wpdb->query(
                $wpdb->prepare(
                    "INSERT IGNORE INTO {$table} (snapshot_at, user_id, user_login, user_email) VALUES (%s, %d, %s, %s)",
                    $now,
                    $user['user_id'],
                    $user['user_login'],
                    $user['user_email']
                )
            );

            if ($result) {
                $inserted++;
            }
        }
        
        // Get the new snapshot that was just created
        $new_snapshot = self::get_latest_snapshot();
        usort($new_snapshot, function($a, $b) {
            return $a['user_id'] <=> $b['user_id'];
        });
        $logger->Info("Latest snapshot: " . json_encode($new_snapshot));
        
        // Delete old snapshots to keep only the 5 most recent
        $wpdb->query(
            "DELETE FROM {$table} WHERE snapshot_at NOT IN (
                SELECT snapshot_at FROM (
                    SELECT DISTINCT snapshot_at FROM {$table} 
                    ORDER BY snapshot_at DESC LIMIT 5
                ) AS recent
            )"
        );
        
        // Return both old and new snapshots
        return [
            'old' => $old_snapshot,
            'new' => $new_snapshot,
        ];
    }

    /**
     * Get the most recent snapshot.
     *
     * @return array Array of rows with user_id, user_login, user_email
     */
    public static function get_latest_snapshot(): array
    {
        global $wpdb;
        $table = self::table_name();

        $latest_time = $wpdb->get_var(
            "SELECT MAX(snapshot_at) FROM {$table}"
        );

        if (empty($latest_time)) {
            return [];
        }

        $results = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT user_id, user_login, user_email FROM {$table} WHERE snapshot_at = %s ORDER BY user_login",
                $latest_time
            ),
            ARRAY_A
        );
        
        // Cast user_id to integer for consistent comparison with current_state
        foreach ($results as &$row) {
            $row['user_id'] = (int) $row['user_id'];
        }
        
        return $results;
    }

    /**
     * Get the previous snapshot (one before the latest).
     *
     * @return array Array of rows with user_id, user_login, user_email
     */
    public static function get_previous_snapshot(): array
    {
        global $wpdb;
        $table = self::table_name();

        $previous_time = $wpdb->get_var(
            "SELECT snapshot_at FROM {$table} ORDER BY snapshot_at DESC LIMIT 1 OFFSET 1"
        );

        if (empty($previous_time)) {
            return [];
        }

        $results = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT user_id, user_login, user_email FROM {$table} WHERE snapshot_at = %s ORDER BY user_login",
                $previous_time
            ),
            ARRAY_A
        );
        
        // Cast user_id to integer for consistent comparison with current_state
        foreach ($results as &$row) {
            $row['user_id'] = (int) $row['user_id'];
        }
        
        return $results;
    }

    /**
     * Compare two snapshots and detect changes.
     *
     * @param array $previous
     * @param array $current
     * @return array ['added' => [], 'removed' => []]
     */
    public static function compare_snapshots(array $previous, array $current): array
    {
        $logger = new CQtrLogger();
        
        // Sort both snapshots by user_id for consistent logging
        usort($previous, function($a, $b) {
            return $a['user_id'] <=> $b['user_id'];
        });
        usort($current, function($a, $b) {
            return $a['user_id'] <=> $b['user_id'];
        });
        
        // Log sorted snapshots
        $logger->Info("Previous snapshot (sorted): " . json_encode($previous));
        $logger->Info("Current snapshot (sorted): " . json_encode($current));
        
        $prev_ids = array_column($previous, 'user_id');
        $curr_ids = array_column($current, 'user_id');

        $logger->Info("Previous IDs: " . json_encode($prev_ids));
        $logger->Info("Current IDs: " . json_encode($curr_ids));

        $added = array_diff($curr_ids, $prev_ids);
        $removed = array_diff($prev_ids, $curr_ids);

        // Map back to user details
        $added_users = array_filter($current, function($u) use ($added) {
            return in_array($u['user_id'], $added, true);
        });

        $removed_users = array_filter($previous, function($u) use ($removed) {
            return in_array($u['user_id'], $removed, true);
        });

        return [
            'added'   => array_values($added_users),
            'removed' => array_values($removed_users),
        ];
    }

    /**
     * Check for admin user changes and raise alarms.
     * Can be called by cron or manually.
     * 
     * Uses hook-aware debouncing to allow each hook to trigger once per request,
     * preventing duplicate checks from the same hook firing multiple times.
     * Different hooks are allowed to each trigger a check.
     *
     * @param string $source Optional source identifier (e.g., 'set_user_role', 'user_register', 'admin_init')
     */
    public static function check_admin_users(): void
    {
        $logger = new CQtrLogger();

        // Create new snapshot and get both old and new snapshots
        $snapshot_result = self::create_snapshot();
        if ($snapshot_result === null) {
            // No changes detected, just finish
            # $logger->Debug("No changes in admin users detected, no snapshot created");
            return;
        }

        // Get snapshots from the result
        $previous = $snapshot_result['old'];
        $current = $snapshot_result['new'];

        if (empty($previous)) {
            // First snapshot, no comparison
            $logger->Info("First admin users snapshot created, no comparison");
            return;
        }

        // Compare
        $changes = self::compare_snapshots($previous, $current);

        // Log the comparison results
        $logger->Info(sprintf(
            "Snapshot comparison: %d added, %d removed",
            count($changes['added']),
            count($changes['removed'])
        ));

        // Raise alarms for changes
        if (!empty($changes['added'])) {
            self::raise_admin_added_alarm($changes['added']);
        }

        if (!empty($changes['removed'])) {
            self::raise_admin_removed_alarm($changes['removed']);
        }

        if (!empty($changes['added']) || !empty($changes['removed'])) {
            $logger->Warning(sprintf(
                "Admin users changed: %d added, %d removed",
                count($changes['added']),
                count($changes['removed'])
            ));
        }
    }

    /**
     * Send alarm immediately when admin user is added via hook.
     * Called immediately after caching the user, before snapshot comparison.
     * This provides instant notification without waiting for snapshot detection.
     *
     * @param int $user_id The user ID
     * @param string $user_login The user login name
     * @param string $user_email The user email
     */
    private static function raise_admin_added_alarm_immediate(int $user_id, string $user_login, string $user_email): void
    {
        $logger = new CQtrLogger();
        $logger->Info("Sending immediate admin.added alarm for: {$user_login} (ID: {$user_id})");
        
        CQtrAlarmsManager::raise(
            CQtrAlarmTypes::TYPE_ADMIN_ADDED,
            ['user_list' => $user_login, 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')],
            true,   // send_email
            true    // force email notification to bypass rate-limiting
        );
    }

    /**
     * Send alarm immediately when admin user role is changed.
     * Called when a user is assigned the administrator role.
     * This provides dedicated notification for role changes.
     *
     * @param int $user_id The user ID
     * @param string $user_login The user login name
     * @param string $user_email The user email
     */
    private static function raise_admin_role_changed_alarm_immediate(int $user_id, string $user_login, string $user_email): void
    {
        $logger = new CQtrLogger();
        $logger->Info("Sending immediate admin.role_changed alarm for: {$user_login} (ID: {$user_id})");
        
        CQtrAlarmsManager::raise(
            CQtrAlarmTypes::TYPE_ADMIN_ROLE_CHANGED,
            ['user_list' => $user_login, 'user_email' => $user_email, 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')],
            true,   // send_email
            true    // force email notification to bypass rate-limiting
        );
    }

    /**
     * Get user data from cache or snapshot.
     * First checks if cached (from hook handler), falls back to current database.
     *
     * @param int $user_id The user ID
     * @param string|null $user_login Optional login name for fallback
     * @return array|null Array with user_id, user_login, user_email or null if not found
     */
    private static function get_user_data(int $user_id, ?string $user_login = null): ?array
    {
        $logger = new CQtrLogger();
        
        // Check cache first
        $cached = get_transient('qtr_added_admin_' . $user_id);
        if ($cached !== false) {
            $logger->Info("User data retrieved from cache: user_id={$user_id}");
            return $cached;
        }
        
        // Fall back to current database lookup
        $user = get_user_by('id', $user_id);
        if ($user instanceof WP_User) {
            $logger->Info("User data retrieved from database: {$user->user_login}");
            return [
                'user_id'    => $user->ID,
                'user_login' => $user->user_login,
                'user_email' => $user->user_email,
            ];
        }
        
        // Last resort: use provided login name if available
        if ($user_login) {
            $logger->Info("Using fallback login name for user_id={$user_id}: {$user_login}");
            return [
                'user_id'    => $user_id,
                'user_login' => $user_login,
                'user_email' => '',
            ];
        }
        
        $logger->Warning("Could not find user data for user_id={$user_id}");
        return null;
    }

    /**
     * Raise alarm when admin users are added.
     *
     * @param array $added_users Array of user rows
     */
    private static function raise_admin_added_alarm(array $added_users): void
    {
        if (empty($added_users)) {
            $logger = new CQtrLogger();
            $logger->Info("No added users to raise admin.added alarm for");
            return;
        }

        $logger = new CQtrLogger();
        
        // Build user list, checking cache first for better data
        $user_names = [];
        foreach ($added_users as $user) {
            // Try to get better data from cache if available
            $user_id = $user['user_id'] ?? null;
            $user_login = $user['user_login'] ?? null;
            
            if ($user_id) {
                $cached_data = self::get_user_data($user_id, $user_login);
                if ($cached_data) {
                    $user_names[] = $cached_data['user_login'];
                    continue;
                }
            }
            
            // Fall back to snapshot data
            if ($user_login) {
                $user_names[] = $user_login;
            }
        }
        
        if (empty($user_names)) {
            $logger->Warning("Could not extract user names from added_users for alarm");
            return;
        }
        
        $user_list = implode(', ', $user_names);
        $logger->Info("Raising admin.added alarm with user_list: {$user_list}");
        
        CQtrAlarmsManager::raise(
            CQtrAlarmTypes::TYPE_ADMIN_ADDED,
            ['user_list' => $user_list, 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')],
            true,   // send_email
            true    // force email notification to bypass rate-limiting
        );
    }

    /**
     * Raise alarm when admin users are removed.
     *
     * @param array $removed_users Array of user rows
     */
    private static function raise_admin_removed_alarm(array $removed_users): void
    {
        if (empty($removed_users)) {
            $logger = new CQtrLogger();
            $logger->Info("No removed users to raise admin.removed alarm for");            
            return;
        }

        $logger = new CQtrLogger();
        
        // Build user list, checking cache first (for removed, check qtr_deleted_admin_ prefix)
        $user_names = [];
        foreach ($removed_users as $user) {
            $user_id = $user['user_id'] ?? null;
            $user_login = $user['user_login'] ?? null;
            
            // For removals, try cache with qtr_deleted_admin_ prefix first
            if ($user_id) {
                $deleted_cache = get_transient('qtr_deleted_admin_' . $user_id);
                if ($deleted_cache !== false) {
                    $user_names[] = $deleted_cache['user_login'];
                    continue;
                }
            }
            
            // Fall back to snapshot data
            if ($user_login) {
                $user_names[] = $user_login;
            }
        }
        
        if (empty($user_names)) {
            $logger->Warning("Could not extract user names from removed_users for alarm");
            return;
        }
        
        $user_list = implode(', ', $user_names);
        $logger->Info("Raising admin.removed alarm for: " . $user_list);
        
        CQtrAlarmsManager::raise(
            CQtrAlarmTypes::TYPE_ADMIN_REMOVED,
            ['user_list' => $user_list, 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')],
            true,   // send_email
            true    // force email notification to bypass rate-limiting
        );
    }

    /**
     * Get all admin users with their snapshot history.
     *
     * @param int $limit Max number of snapshots to return
     * @return array Array organized by snapshot time
     */
    public static function get_admin_users_history(int $limit = 10): array
    {
        global $wpdb;
        $table = self::table_name();

        $results = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT snapshot_at, user_id, user_login, user_email 
                 FROM {$table} 
                 ORDER BY snapshot_at DESC 
                 LIMIT %d",
                $limit
            )
        );

        // Organize by snapshot time
        $history = [];
        foreach ($results as $row) {
            if (!isset($history[$row->snapshot_at])) {
                $history[$row->snapshot_at] = [];
            }
            $history[$row->snapshot_at][] = [
                'user_id'    => $row->user_id,
                'user_login' => $row->user_login,
                'user_email' => $row->user_email,
            ];
        }

        return $history;
    }

    /**
     * Handle user registration - check if new user is an admin.
     * NOTE: This fires early, user role might be assigned in another hook.
     * We use priority 20 to ensure other hooks complete first, and we also
     * trigger on admin_init as a safety net.
     *
     * @param int $user_id The ID of the newly registered user
     */
    public static function on_user_added(int $user_id): void
    {
        $logger = new CQtrLogger();
        $logger->Info("on_user_added() called for user_id: {$user_id}");
        
        $user = get_user_by('id', $user_id);
        if (!$user instanceof WP_User) {
            $logger->Info("User not found: {$user_id}");
            return;
        }

        $logger->Info("User found: {$user->user_login}, roles: " . implode(', ', (array)$user->roles));

        // Check if user has administrator role
        if (in_array('administrator', (array)$user->roles, true)) {
            $logger->Info("User {$user->user_login} is admin, triggering check");
            
            // Cache the user data immediately (same as deletion mechanism)
            // This provides redundancy if snapshot comparison misses the change
            set_transient('qtr_added_admin_' . $user_id, [
                'user_login' => $user->user_login,
                'user_email' => $user->user_email,
                'user_id'    => $user->ID,
            ], MINUTE_IN_SECONDS);
                       
            $logger->Warning("Admin user added: {$user->user_login} (ID: {$user_id})");

        } else {
            $logger->Info("User {$user->user_login} is NOT admin, skipping check");
        }
    }

    /**
     * Handle when user role is changed.
     * This hook fires when a role is assigned via set_user_role()
     *
     * @param int $user_id The user ID
     * @param string $new_role The new role
     * @param string[] $old_roles The old roles
     */
    public static function on_user_role_changed(int $user_id, string $new_role, array $old_roles): void
    {
        $logger = new CQtrLogger();
        $logger->Info("User role changed called for user_id: {$user_id}, new_role: {$new_role}");
        
        // Check if the new role is administrator
        if ($new_role === 'administrator' || (is_array($new_role) && in_array('administrator', $new_role, true))) {
            // User is now an admin
            $user = get_user_by('id', $user_id);
            if ($user instanceof WP_User) {
                $logger->Info("User {$user->user_login} assigned administrator role");
                
                // Cache the user data immediately (same as deletion mechanism)
                // This provides redundancy if snapshot comparison misses the change
                set_transient('qtr_added_admin_' . $user_id, [
                    'user_login' => $user->user_login,
                    'user_email' => $user->user_email,
                    'user_id'    => $user->ID,
                ], MINUTE_IN_SECONDS);
                
                $logger->Warning("Admin role assigned to user: {$user->user_login} (ID: {$user_id})");
                
            }
        }
    }

    /**
     * Handle user deletion - check if deleted user was an admin.
     * NOTE: deleted_user hook fires AFTER user is deleted from database.
     * This method can only trigger a comparison check.
     *
     * @param int $user_id The ID of the deleted user
     */
    public static function on_user_removed(int $user_id): void
    {
        // Force immediate check to detect the removal
        // This will compare current admins against previous snapshot
        $logger = new CQtrLogger();
        $logger->Warning("User {$user_id} removed");
    }

    /**
     * Handle user deletion BEFORE the user is removed.
     * This hook fires before deletion, allowing us to capture user data.
     *
     * @param int $user_id The ID of the user being deleted
     */
    public static function on_delete_user(int $user_id): void
    {
        // Get user object before they're deleted
        $user = get_user_by('id', $user_id);
        if (!$user instanceof WP_User) {
            return;
        }

        // Check if user is an administrator
        if (in_array('administrator', (array)$user->roles, true)) {
            // Capture user info before deletion
            $logger = new CQtrLogger();
            $logger->Warning("Admin user marked for deletion: {$user->user_login} (ID: {$user_id})");
            
            // Store in transient temporarily so we can reference it after deletion
            set_transient('qtr_deleted_admin_' . $user_id, [
                'user_login' => $user->user_login,
                'user_email' => $user->user_email,
                'user_id'    => $user->ID,
            ], MINUTE_IN_SECONDS);
        }
    }

    /**
     * Uninstall: remove table, cron, and all cached transient data.
     */
    public static function uninstall(): void
    {
        global $wpdb;

        // Remove cron
        wp_clear_scheduled_hook(self::CRON_HOOK);

        // Drop table
        $table = self::table_name();
        $wpdb->query("DROP TABLE IF EXISTS {$table}");

        // Remove option
        delete_option(self::OPTION_LAST_CHECK);

        // Remove all transient caches related to admin user monitoring
        // Delete qtr_added_admin_* transients
        $wpdb->query(
            "DELETE FROM {$wpdb->options} 
             WHERE option_name LIKE '_transient_qtr_added_admin_%' 
             OR option_name LIKE '_transient_timeout_qtr_added_admin_%'"
        );

        // Delete qtr_deleted_admin_* transients
        $wpdb->query(
            "DELETE FROM {$wpdb->options} 
             WHERE option_name LIKE '_transient_qtr_deleted_admin_%' 
             OR option_name LIKE '_transient_timeout_qtr_deleted_admin_%'"
        );
    }
}
