<?php
/**
 * Handles Session Tracking and Monitoring for Guard Dog.
 *
 * Tracks active user sessions in the database to enable remote logout,
 * suspicious activity detection (IP shifts, concurrent geo sessions),
 * and session management from the admin dashboard.
 *
 * @package GuardDog
 * @since 1.9.1
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

class Guard_Dog_Session_Manager {

    /**
     * Session meta key stored on the user for quick session count lookup.
     * @var string
     */
    const USER_META_SESSION_COUNT = '_guard_dog_session_count';

    /**
     * User meta key that indicates a user has had at least one tracked session.
     *
     * Used by validate_current_session() to distinguish between:
     * - Users who logged in before session tracking was enabled (no meta → backfill)
     * - Users whose session expired and was cleaned up (meta exists → force logout)
     *
     * @var string
     */
    const USER_META_HAS_SESSION = '_guard_dog_has_session';

    /**
     * Transient key prefix for heartbeat debouncing.
     * @var string
     */
    const HEARTBEAT_TRANSIENT_PREFIX = 'guard_dog_heartbeat_';

    /**
     * Heartbeat debounce interval in seconds (5 minutes).
     * @var int
     */
    const HEARTBEAT_INTERVAL = 300;

    /**
     * Default session timeout in minutes.
     * @var int
     */
    const DEFAULT_SESSION_TIMEOUT = 60;

    /**
     * Default maximum sessions per user.
     * @var int
     */
    const DEFAULT_MAX_SESSIONS = 5;

    /**
     * Activity log instance.
     * @var Guard_Dog_Activity_Log|null
     */
    private $activity_log = null;

    /**
     * IP reputation checker instance.
     * @var Guard_Dog_Ip_Reputation_Checker|null
     */
    private $ip_reputation_checker = null;

    /**
     * Constructor. Adds hooks if session management is enabled.
     *
     * @since 1.9.1
     * @param Guard_Dog_Activity_Log|null $activity_log_instance Activity log instance.
     * @param Guard_Dog_Ip_Reputation_Checker|null $ip_reputation_checker IP reputation instance.
     */
    public function __construct( $activity_log_instance = null, $ip_reputation_checker = null ) {
        $this->activity_log = $activity_log_instance;
        $this->ip_reputation_checker = $ip_reputation_checker;

        if ( $this->is_enabled() ) {
            $this->add_hooks();
        }
    }

    /**
     * Check if session management is enabled.
     *
     * @since 1.9.1
     * @return bool
     */
    public function is_enabled() {
        return (bool) get_option( 'guard_dog_sessions_enabled', true );
    }

    /**
     * Get the maximum allowed sessions per user.
     *
     * @since 1.9.1
     * @return int
     */
    public function get_max_sessions_per_user() {
        return max( 1, (int) get_option( 'guard_dog_sessions_max_per_user', self::DEFAULT_MAX_SESSIONS ) );
    }

    /**
     * Get session timeout in minutes.
     *
     * @since 1.9.1
     * @return int
     */
    public function get_session_timeout_minutes() {
        return (int) get_option( 'guard_dog_sessions_timeout_minutes', self::DEFAULT_SESSION_TIMEOUT );
    }

    /**
     * Check if IP shift detection is enabled.
     *
     * @since 1.9.1
     * @return bool
     */
    public function is_ip_shift_detection_enabled() {
        return (bool) get_option( 'guard_dog_sessions_detect_ip_shift', true );
    }

    /**
     * Check if geo anomaly detection is enabled.
     *
     * @since 1.9.1
     * @return bool
     */
    public function is_geo_anomaly_detection_enabled() {
        return (bool) get_option( 'guard_dog_sessions_detect_geo_anomaly', true );
    }

    /**
     * Adds the necessary WordPress hooks.
     *
     * @since 1.9.1
     */
    private function add_hooks() {
        // Session lifecycle: create on set_logged_in_cookie (token is available there).
        // wp_login runs after wp_set_auth_cookie, but the new cookie is not in $_COOKIE
        // until the next request—get_current_session_token() would always be empty on wp_login.
        add_action( 'set_logged_in_cookie', array( $this, 'on_set_logged_in_cookie' ), 10, 6 );
        add_action( 'wp_logout', array( $this, 'destroy_current_session' ), 10 );
        add_action( 'clear_auth_cookie', array( $this, 'destroy_current_session' ), 10 );

        // Heartbeat - update activity timestamp on every request type.
        // admin_init covers admin page loads and admin-ajax.php requests.
        // wp_enqueue_scripts covers frontend page loads.
        // rest_api_init covers REST API requests (Gutenberg block editor, autosave,
        // post locking, and other modern WP admin features that communicate via REST).
        // Without rest_api_init, REST-only activity validates sessions without extending
        // them, causing premature expiration when the WP AJAX heartbeat is throttled.
        add_action( 'admin_init', array( $this, 'update_heartbeat' ) );
        add_action( 'wp_enqueue_scripts', array( $this, 'update_heartbeat' ) );
        add_action( 'rest_api_init', array( $this, 'update_heartbeat' ) );

        // Session validation on every request (early priority)
        add_action( 'init', array( $this, 'validate_current_session' ), 1 );

        // Cleanup cron job
        if ( ! wp_next_scheduled( 'guard_dog_cleanup_sessions' ) ) {
            wp_schedule_event( time(), 'hourly', 'guard_dog_cleanup_sessions' );
        }
        add_action( 'guard_dog_cleanup_sessions', array( $this, 'cleanup_expired_sessions' ) );

        // AJAX handlers for session management
        add_action( 'wp_ajax_guard_dog_terminate_session', array( $this, 'ajax_terminate_session' ) );
        add_action( 'wp_ajax_guard_dog_terminate_all_sessions', array( $this, 'ajax_terminate_all_sessions' ) );
        add_action( 'wp_ajax_guard_dog_get_user_sessions', array( $this, 'ajax_get_user_sessions' ) );

        // User profile integration
        add_action( 'show_user_profile', array( $this, 'render_user_sessions_section' ), 30 );
        add_action( 'edit_user_profile', array( $this, 'render_user_sessions_section' ), 30 );

        // Admin scripts
        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
    }

    //--------------------------------------------------------------------------
    // Session CRUD Operations
    //--------------------------------------------------------------------------

    /**
     * Handle set_logged_in_cookie: create session when WordPress sets the auth cookie.
     * The token is passed here; it is not yet in $_COOKIE when wp_login runs.
     *
     * @since 1.9.1
     * @param string $logged_in_cookie The logged-in cookie value.
     * @param int    $expire           The time the login grace period expires.
     * @param int    $expiration       When the authentication cookie expires.
     * @param int    $user_id          User ID.
     * @param string $scheme           Scheme; must be 'logged_in' to create a session.
     * @param string $token            User's session token.
     */
    public function on_set_logged_in_cookie( $logged_in_cookie, $expire, $expiration, $user_id, $scheme, $token ) {
        if ( 'logged_in' !== $scheme || empty( $token ) || ! $user_id ) {
            return;
        }
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return;
        }
        $this->create_session( $user->user_login, $user, $token );
    }

    /**
     * Create a new session record when user logs in.
     *
     * @since 1.9.1
     * @param string   $user_login   The username.
     * @param WP_User  $user         The user object.
     * @param string|null $token     Optional. Session token; if omitted, read from auth cookie (works only on subsequent requests).
     */
    public function create_session( $user_login, $user, $token = null ) {
        global $wpdb;

        $user_id = $user->ID;
        $ip_address = $this->get_client_ip();
        $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
        $session_token = ( null !== $token && '' !== $token ) ? $token : $this->get_current_session_token();

        if ( empty( $session_token ) ) {
            Guard_Dog()->debug->warning( 'Session Manager: Could not get session token during login', 'session_manager' );
            return;
        }

        $session_token_hash = hash( 'sha256', $session_token );
        $device_fingerprint = $this->generate_device_fingerprint( $user_agent, $ip_address );

        // Get geo data from IP reputation checker
        $geo_data = $this->get_geo_data( $ip_address );

        // Best-effort per-user lock to reduce race windows between concurrent logins.
        $lock_key = $this->acquire_user_session_lock( $user_id );

        try {
            // Clean up any orphaned sessions (Guard Dog records whose WP token no
            // longer exists) before creating a new session.  This handles the case
            // where a WordPress auth cookie expired independently and the Guard Dog
            // record was never removed.
            $this->cleanup_orphaned_sessions( $user_id );

            // Enforce session limit before creating new session
            $this->enforce_session_limit( $user_id, $session_token_hash );

            // Calculate expiration
            $timeout_minutes = $this->get_session_timeout_minutes();
            $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $timeout_minutes * 60 ) );

            $table_name = Guard_Dog()->db->get_sessions_table_name();

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
            // Direct database insert is necessary for session management functionality
            $result = $wpdb->insert(
                $table_name,
                array(
                    'user_id'            => $user_id,
                    'session_token_hash' => $session_token_hash,
                    'ip_address'         => $ip_address,
                    'user_agent'         => $user_agent,
                    'device_fingerprint' => $device_fingerprint,
                    'geo_country'        => $geo_data['country'] ?? null,
                    'geo_city'           => $geo_data['city'] ?? null,
                    'created_at'         => current_time( 'mysql', true ),
                    'last_activity'      => current_time( 'mysql', true ),
                    'expires_at'         => $expires_at,
                    'is_compromised'     => 0,
                    'compromised_reason' => null,
                ),
                array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s' )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery

            if ( false === $result ) {
                Guard_Dog()->debug->error( 'Session Manager: Failed to create session', 'session_manager', array(
                    'user_id'  => $user_id,
                    'db_error' => $wpdb->last_error,
                ) );
                return;
            }

            // Re-enforce after insert to catch concurrent inserts that land between
            // pre-check and insert. Protect the just-created token from being pruned.
            $this->enforce_session_limit( $user_id, $session_token_hash );

            // Mark that this user has had at least one tracked session.
            // Used by validate_current_session() to distinguish "never tracked" from
            // "session expired and was cleaned up" — preventing backfill from defeating timeouts.
            update_user_meta( $user_id, self::USER_META_HAS_SESSION, '1' );

            // Log the event
            if ( $this->activity_log ) {
                $this->activity_log->add_log_entry( 'session_created', array(
                    'user_id'     => $user_id,
                    'ip_address'  => $ip_address,
                    'geo_country' => $geo_data['country'] ?? 'Unknown',
                    'user_agent'  => $user_agent,
                ) );
            }

            Guard_Dog()->debug->info( 'Session Manager: Session created', 'session_manager', array(
                'user_id'    => $user_id,
                'session_id' => $wpdb->insert_id,
            ) );
        } finally {
            $this->release_user_session_lock( $lock_key );
        }
    }

    /**
     * Destroy the current user's session record on logout.
     *
     * @since 1.9.1
     */
    public function destroy_current_session() {
        $user_id = get_current_user_id();
        if ( ! $user_id ) {
            return;
        }

        $session_token = $this->get_current_session_token();
        if ( empty( $session_token ) ) {
            return;
        }

        $session_token_hash = hash( 'sha256', $session_token );
        $this->destroy_session_by_hash( $session_token_hash, $user_id );
    }

    /**
     * Destroy a session by its token hash.
     *
     * @since 1.9.1
     * @param string $session_token_hash The hashed session token.
     * @param int $user_id The user ID (for logging).
     * @param string $reason Optional reason for termination.
     */
    private function destroy_session_by_hash( $session_token_hash, $user_id, $reason = 'logout' ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_sessions_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        // Direct database delete is necessary for session management
        // Caching is not appropriate for session data as it needs real-time accuracy
        $result = $wpdb->delete(
            $table_name,
            array( 'session_token_hash' => $session_token_hash ),
            array( '%s' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( $result && $this->activity_log && 'logout' !== $reason ) {
            $this->activity_log->add_log_entry( 'session_terminated', array(
                'user_id' => $user_id,
                'reason'  => $reason,
            ) );
        }
    }

    /**
     * Get a session by its ID.
     *
     * @since 1.9.1
     * @param int $session_id The session ID.
     * @return object|null Session data or null if not found.
     */
    public function get_session( $session_id ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_sessions_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        // Direct database query is necessary for session retrieval
        // Table name is validated by Guard_Dog_DB
        // Caching is not appropriate for session data
        $escaped_table = esc_sql( $table_name );
        $session = $wpdb->get_row(
            $wpdb->prepare( "SELECT * FROM $escaped_table WHERE session_id = %d", $session_id )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $session;
    }

    /**
     * Get all sessions for a user.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @param bool $active_only Only return non-expired sessions.
     * @return array Array of session objects.
     */
    public function get_user_sessions( $user_id, $active_only = true ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_sessions_table_name();
        $escaped_table = esc_sql( $table_name );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        // Direct database query is necessary for session listing
        // Table name is validated by Guard_Dog_DB
        // Caching is not appropriate for session data
        if ( $active_only ) {
            $sessions = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM $escaped_table WHERE user_id = %d AND expires_at > %s ORDER BY last_activity DESC",
                    $user_id,
                    current_time( 'mysql', true )
                )
            );
        } else {
            $sessions = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM $escaped_table WHERE user_id = %d ORDER BY last_activity DESC",
                    $user_id
                )
            );
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $sessions ?? array();
    }

    /**
     * Terminate a specific session.
     *
     * @since 1.9.1
     * @param int $session_id The session ID to terminate.
     * @param string $reason Reason for termination.
     * @return bool Success status.
     */
    public function terminate_session( $session_id, $reason = 'admin_action' ) {
        global $wpdb;

        $session = $this->get_session( $session_id );
        if ( ! $session ) {
            return false;
        }

        $table_name = Guard_Dog()->db->get_sessions_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        // Direct database delete is necessary for session termination
        $result = $wpdb->delete(
            $table_name,
            array( 'session_id' => $session_id ),
            array( '%d' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( $result ) {
            // Destroy WordPress session token
            $this->destroy_wp_session_token( $session->user_id, $session->session_token_hash );

            if ( $this->activity_log ) {
                $this->activity_log->add_log_entry( 'session_terminated', array(
                    'user_id'    => $session->user_id,
                    'session_id' => $session_id,
                    'reason'     => $reason,
                    'ip_address' => $session->ip_address,
                ) );
            }

            return true;
        }

        return false;
    }

    /**
     * Terminate all sessions for a user except the current one.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @param bool $include_current Whether to include the current session.
     * @return int Number of sessions terminated.
     */
    public function terminate_all_user_sessions( $user_id, $include_current = false ) {
        global $wpdb;

        $current_token_hash = null;
        if ( ! $include_current && get_current_user_id() === $user_id ) {
            $session_token = $this->get_current_session_token();
            if ( $session_token ) {
                $current_token_hash = hash( 'sha256', $session_token );
            }
        }

        $sessions = $this->get_user_sessions( $user_id );
        $terminated = 0;

        foreach ( $sessions as $session ) {
            // Skip current session if requested
            if ( ! $include_current && $current_token_hash && $session->session_token_hash === $current_token_hash ) {
                continue;
            }

            if ( $this->terminate_session( $session->session_id, 'terminate_all' ) ) {
                $terminated++;
            }
        }

        return $terminated;
    }

    /**
     * Remove Guard Dog session records that no longer have a corresponding
     * WordPress session token.
     *
     * Guard Dog tracks sessions in a custom table, but WordPress manages the
     * authoritative session tokens in user meta ('session_tokens'). Records can
     * become orphaned when WordPress invalidates auth cookies independently
     * (e.g. cookie expiration, another plugin destroying the token, auth key
     * rotation during deployment). In those cases validate_current_session()
     * never runs (is_user_logged_in() returns false), leaving stale records.
     *
     * @since 1.9.34
     * @param int $user_id The user ID whose sessions to audit.
     * @return int Number of orphaned sessions removed.
     */
    public function cleanup_orphaned_sessions( $user_id ) {
        global $wpdb;

        // WordPress stores session tokens in user meta keyed by SHA-256 hash.
        // Guard Dog stores the same hash in session_token_hash.
        $wp_tokens = $this->get_authoritative_wp_session_tokens( $user_id );

        // A genuinely-empty token map can happen transiently around login/logout
        // boundaries or unusual auth flows. Bail out defensively instead of mass
        // deleting Guard Dog records and forcing false session expiry on next request.
        if ( empty( $wp_tokens ) ) {
            return 0;
        }

        // Fetch ALL Guard Dog sessions for this user (including expired).
        $sessions = $this->get_user_sessions( $user_id, false );
        $removed = 0;

        $table_name = Guard_Dog()->db->get_sessions_table_name();

        foreach ( $sessions as $session ) {
            if ( ! isset( $wp_tokens[ $session->session_token_hash ] ) ) {
                // Delete only the Guard Dog tracking record. Do NOT call
                // terminate_session() here — that method also destroys the
                // WordPress session token via destroy_wp_session_token().
                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->delete(
                    $table_name,
                    array( 'session_id' => $session->session_id ),
                    array( '%d' )
                );
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $removed++;
            }
        }

        if ( $removed > 0 ) {
            Guard_Dog()->debug->info( 'Session Manager: Cleaned up orphaned sessions', 'session_manager', array(
                'user_id' => $user_id,
                'removed' => $removed,
            ) );
        }

        return $removed;
    }

    //--------------------------------------------------------------------------
    // Session Validation & Anomaly Detection
    //--------------------------------------------------------------------------

    /**
     * Determine whether a session should be backfilled for a user.
     *
     * Returns true only if the user has never had a tracked Guard Dog session
     * (i.e. they logged in before session tracking was enabled). Returns false
     * if the user previously had a session that expired — in that case, the
     * caller should force logout to enforce the timeout.
     *
     * @since 1.9.30
     * @param int $user_id The user ID.
     * @return bool True if backfill is appropriate, false if the user should be logged out.
     */
    public function should_backfill_session( $user_id ) {
        $has_previous_session = get_user_meta( $user_id, self::USER_META_HAS_SESSION, true );
        return empty( $has_previous_session );
    }

    /**
     * Validate the current session on each request.
     *
     * @since 1.9.1
     */
    public function validate_current_session() {
        if ( ! is_user_logged_in() ) {
            return;
        }

        $user_id = get_current_user_id();
        $session_token = $this->get_current_session_token();

        if ( empty( $session_token ) ) {
            return;
        }

        $session_token_hash = hash( 'sha256', $session_token );
        $session = $this->get_session_by_hash( $session_token_hash );

        if ( ! $session ) {
            // No session record found for this token. Two possible scenarios:
            //
            // 1. User logged in before session tracking was enabled (no meta flag).
            //    → Backfill: create a session record so tracking starts now.
            //
            // 2. User previously had a tracked session that expired and was cleaned up
            //    by the cron job (meta flag exists). → Force logout to enforce timeout.
            if ( ! $this->should_backfill_session( $user_id ) ) {
                // Session was tracked but no longer exists → expired and cleaned up.
                Guard_Dog()->debug->warning( 'Session Manager: session_death — record not found', 'session_manager', array(
                    'user_id'    => $user_id,
                    'token_hash' => substr( $session_token_hash, 0, 12 ) . '...',
                    'has_meta'   => (bool) get_user_meta( $user_id, self::USER_META_HAS_SESSION, true ),
                    'is_admin'   => $this->is_admin_page_request(),
                    'is_ajax'    => wp_doing_ajax(),
                    'is_rest'    => defined( 'REST_REQUEST' ) && REST_REQUEST,
                ) );
                if ( $this->is_admin_page_request() ) {
                    $this->force_logout( $user_id, 'session_expired' );
                } else {
                    $this->silent_expire( $user_id );
                }
                return;
            }

            // Backfill: user has a valid WP cookie but was never tracked by Guard Dog
            // (e.g. logged in before session tracking was enabled). Create session now.
            $user = get_userdata( $user_id );
            if ( $user ) {
                $this->create_session( $user->user_login, $user );
            }
            return;
        }

        // Check if session is expired.
        // expires_at is stored as UTC via gmdate(). Append ' UTC' so strtotime()
        // interprets it in UTC regardless of PHP's date.timezone setting. Without
        // this, a server with timezone ahead of UTC (e.g. Asia/Kolkata = UTC+5:30)
        // would parse the UTC datetime as local time, causing sessions to appear
        // expired up to 12 hours early.
        if ( strtotime( $session->expires_at . ' UTC' ) < time() ) {
            Guard_Dog()->debug->warning( 'Session Manager: session_death — expired', 'session_manager', array(
                'user_id'    => $user_id,
                'expires_at' => $session->expires_at,
                'now_utc'    => gmdate( 'Y-m-d H:i:s' ),
                'is_admin'   => $this->is_admin_page_request(),
                'is_ajax'    => wp_doing_ajax(),
                'is_rest'    => defined( 'REST_REQUEST' ) && REST_REQUEST,
            ) );
            if ( $this->is_admin_page_request() ) {
                $this->force_logout( $user_id, 'session_expired' );
            } else {
                $this->silent_expire( $user_id );
            }
            return;
        }

        // Check if session is marked as compromised
        if ( $session->is_compromised ) {
            $this->force_logout( $user_id, 'session_compromised' );
            return;
        }

        $current_ip = $this->get_client_ip();

        // IP shift detection
        if ( $this->is_ip_shift_detection_enabled() && $session->ip_address !== $current_ip ) {
            // Skip shift detection when either IP is private/reserved. This handles:
            // 1. Docker/container IP changes (172.17.0.x bridge network).
            // 2. Load balancer internal routing (10.x, 192.168.x).
            // 3. Container restarts where both IPs are private.
            // Private IPs have no valid geolocation, so shift detection would
            // always see a "country change" and incorrectly kill the session.
            $involves_private_ip = Guard_Dog_Utils::is_private_or_reserved_ip( $session->ip_address )
                || Guard_Dog_Utils::is_private_or_reserved_ip( $current_ip );

            // Skip shift detection when either IP is a trusted proxy. This handles:
            // 1. Proxy rotation: both IPs are proxy IPs (load balancer failover).
            // 2. Proxy direct requests: health checks, cache fills, or internal
            //    requests from the proxy itself arrive without forwarded headers,
            //    so get_client_ip() resolves to the proxy IP instead of the user's
            //    real IP. This is infrastructure noise, not a real IP shift.
            // Only applies when a trusted proxy list is explicitly configured.
            $proxy_list_configured = ! empty( get_option( 'guard_dog_trusted_proxy_ips', '' ) );
            $involves_proxy = $proxy_list_configured
                && ( Guard_Dog_Utils::is_trusted_proxy( $session->ip_address )
                    || Guard_Dog_Utils::is_trusted_proxy( $current_ip ) );

            if ( ! $involves_private_ip && ! $involves_proxy ) {
                $this->handle_ip_shift( $session, $current_ip );
            }
        }

        // Geo anomaly detection for concurrent sessions
        if ( $this->is_geo_anomaly_detection_enabled() ) {
            $this->detect_concurrent_geo_anomaly( $user_id, $session );
        }

        // IP reputation check during session
        if ( $this->is_session_ip_reputation_enabled() ) {
            $this->check_session_ip_reputation( $session, $current_ip );
        }
    }

    /**
     * Check if session IP reputation checking is enabled.
     *
     * @since 1.9.1
     * @return bool
     */
    public function is_session_ip_reputation_enabled() {
        return (bool) get_option( 'guard_dog_ip_reputation_session_enabled', true );
    }

    /**
     * Get the session IP reputation termination threshold.
     *
     * @since 1.9.1
     * @return int
     */
    public function get_session_ip_reputation_threshold() {
        return (int) get_option( 'guard_dog_ip_reputation_session_threshold', 90 );
    }

    /**
     * Check IP reputation during session validation.
     *
     * @since 1.9.1
     * @param object $session The session object.
     * @param string $current_ip The current IP address.
     */
    private function check_session_ip_reputation( $session, $current_ip ) {
        if ( ! $this->ip_reputation_checker ) {
            return;
        }

        // Debounce reputation checks (once per hour per session)
        $transient_key = 'guard_dog_ip_rep_check_' . $session->session_id;
        if ( get_transient( $transient_key ) ) {
            return; // Already checked recently.
        }

        $result = $this->ip_reputation_checker->check_ip_reputation( $current_ip );

        // Skip if API failed.
        if ( ! $result['success'] ) {
            // Short backoff for provider failures; avoid disabling checks for a full hour.
            set_transient( $transient_key, 'failed', MINUTE_IN_SECONDS * 5 );
            return;
        }

        set_transient( $transient_key, true, HOUR_IN_SECONDS );

        $abuse_confidence = isset( $result['abuse_confidence'] ) ? (int) $result['abuse_confidence'] : 0;
        $threshold = $this->get_session_ip_reputation_threshold();

        // Check if IP exceeds critical threshold
        if ( $abuse_confidence >= $threshold ) {
            $this->handle_malicious_ip_session( $session, $current_ip, $result );
        }
    }

    /**
     * Handle a session from a malicious IP.
     *
     * @since 1.9.1
     * @param object $session The session object.
     * @param string $ip The malicious IP address.
     * @param array $reputation_data IP reputation data.
     */
    private function handle_malicious_ip_session( $session, $ip, $reputation_data ) {
        global $wpdb;

        // Mark session as compromised.
        $sessions_table = Guard_Dog()->db->get_sessions_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $sessions_table,
            array(
                'is_compromised'     => 1,
                'compromised_reason' => sprintf(
                    /* translators: %d is the abuse confidence score */
                    __( 'IP reputation score: %d%%', 'guard-dog' ),
                    $reputation_data['abuse_confidence']
                ),
            ),
            array( 'session_id' => $session->session_id ),
            array( '%d', '%s' ),
            array( '%d' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        // Log the event.
        if ( $this->activity_log ) {
            $this->activity_log->add_log_entry( 'session_terminated_ip_reputation', array(
                'user_id'    => $session->user_id,
                'ip_address' => $ip,
                'details'    => array(
                    'session_id'        => $session->session_id,
                    'abuse_confidence'  => $reputation_data['abuse_confidence'],
                    'status'            => $reputation_data['status'],
                    'country'           => isset( $reputation_data['country'] ) ? $reputation_data['country'] : '',
                    'threat_indicators' => isset( $reputation_data['threat_indicators'] ) ? $reputation_data['threat_indicators'] : array(),
                ),
            ) );
        }

        // Send notification.
        $this->send_suspicious_activity_notification( $session->user_id, 'ip_reputation', array(
            'session_id'       => $session->session_id,
            'ip'               => $ip,
            'abuse_confidence' => $reputation_data['abuse_confidence'],
            'country'          => isset( $reputation_data['country'] ) ? $reputation_data['country'] : '',
        ) );

        // Force logout for this session.
        $this->force_logout( $session->user_id, 'ip_reputation' );
    }

    /**
     * Handle IP shift detection.
     *
     * @since 1.9.1
     * @param object $session The session object.
     * @param string $new_ip The new IP address.
     */
    private function handle_ip_shift( $session, $new_ip ) {
        global $wpdb;

        // Get geo data for both IPs
        $old_geo = array(
            'country' => $session->geo_country,
            'city'    => $session->geo_city,
        );
        $new_geo = $this->get_geo_data( $new_ip );

        // Check if this is a significant shift (different country).
        // Both countries must be non-empty for a meaningful comparison. When either
        // is null/empty (e.g., geo-lookup failed or private IP leaked through),
        // we cannot determine significance — treat as NOT significant to avoid
        // false positive session termination.
        $old_country = $old_geo['country'] ?? null;
        $new_country = $new_geo['country'] ?? null;
        $is_significant = ! empty( $old_country )
            && ! empty( $new_country )
            && $old_country !== $new_country;

        if ( $is_significant ) {
            // Mark session as potentially compromised
            $table_name = Guard_Dog()->db->get_sessions_table_name();

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update(
                $table_name,
                array(
                    'is_compromised'     => 1,
                    'compromised_reason' => sprintf(
                        'IP shifted from %s (%s) to %s (%s)',
                        $session->ip_address,
                        $old_geo['country'] ?? 'Unknown',
                        $new_ip,
                        $new_geo['country'] ?? 'Unknown'
                    ),
                ),
                array( 'session_id' => $session->session_id ),
                array( '%d', '%s' ),
                array( '%d' )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

            if ( $this->activity_log ) {
                $this->activity_log->add_log_entry( 'session_ip_shift', array(
                    'user_id'     => $session->user_id,
                    'session_id'  => $session->session_id,
                    'old_ip'      => $session->ip_address,
                    'new_ip'      => $new_ip,
                    'old_country' => $old_geo['country'] ?? 'Unknown',
                    'new_country' => $new_geo['country'] ?? 'Unknown',
                ) );
            }

            // Send notifications
            $this->send_suspicious_activity_notification( $session->user_id, 'ip_shift', array(
                'session_id'  => $session->session_id,
                'old_ip'      => $session->ip_address,
                'new_ip'      => $new_ip,
                'old_country' => $old_geo['country'] ?? 'Unknown',
                'new_country' => $new_geo['country'] ?? 'Unknown',
            ) );
        } else {
            // Just update the IP without flagging (same country, different IP is normal)
            $table_name = Guard_Dog()->db->get_sessions_table_name();

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update(
                $table_name,
                array(
                    'ip_address'  => $new_ip,
                    'geo_country' => $new_geo['country'] ?? $session->geo_country,
                    'geo_city'    => $new_geo['city'] ?? $session->geo_city,
                ),
                array( 'session_id' => $session->session_id ),
                array( '%s', '%s', '%s' ),
                array( '%d' )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        }
    }

    /**
     * Detect concurrent sessions from different geographic locations.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @param object $current_session The current session.
     */
    private function detect_concurrent_geo_anomaly( $user_id, $current_session ) {
        $sessions = $this->get_user_sessions( $user_id );
        $wp_tokens = $this->get_authoritative_wp_session_tokens( $user_id );

        // Only evaluate sessions still backed by an active WordPress token.
        if ( empty( $wp_tokens ) ) {
            return;
        }

        $sessions = array_filter(
            $sessions,
            function ( $session ) use ( $wp_tokens ) {
                return isset( $wp_tokens[ $session->session_token_hash ] );
            }
        );

        if ( count( $sessions ) < 2 ) {
            return;
        }

        $countries = array();
        foreach ( $sessions as $session ) {
            if ( ! empty( $session->geo_country ) && ! $session->is_compromised ) {
                $countries[ $session->geo_country ][] = $session->session_id;
            }
        }

        // If sessions exist in more than one country simultaneously
        if ( count( $countries ) > 1 ) {
            // Check if this anomaly was already flagged recently (within 1 hour)
            $transient_key = 'guard_dog_geo_anomaly_' . $user_id;
            if ( get_transient( $transient_key ) ) {
                return; // Already notified recently
            }

            set_transient( $transient_key, true, HOUR_IN_SECONDS );

            if ( $this->activity_log ) {
                $this->activity_log->add_log_entry( 'session_geo_anomaly', array(
                    'user_id'   => $user_id,
                    'countries' => array_keys( $countries ),
                    'sessions'  => array_values( $countries ),
                ) );
            }

            // Send notifications
            $this->send_suspicious_activity_notification( $user_id, 'geo_anomaly', array(
                'countries' => array_keys( $countries ),
            ) );
        }
    }

    //--------------------------------------------------------------------------
    // Heartbeat & Session Limit Enforcement
    //--------------------------------------------------------------------------

    /**
     * Update the last_activity timestamp (debounced).
     *
     * @since 1.9.1
     */
    public function update_heartbeat() {
        if ( ! is_user_logged_in() ) {
            return;
        }

        $session_token = $this->get_current_session_token();

        if ( empty( $session_token ) ) {
            return;
        }

        $session_token_hash = hash( 'sha256', $session_token );

        // Debounce: Only update every 5 minutes
        $transient_key = self::HEARTBEAT_TRANSIENT_PREFIX . $session_token_hash;
        if ( get_transient( $transient_key ) ) {
            return;
        }

        global $wpdb;
        $table_name = Guard_Dog()->db->get_sessions_table_name();

        // Update last_activity and extend expiration
        $timeout_minutes = $this->get_session_timeout_minutes();
        $new_expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $timeout_minutes * 60 ) );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $updated = $wpdb->update(
            $table_name,
            array(
                'last_activity' => current_time( 'mysql', true ),
                'expires_at'    => $new_expires_at,
            ),
            array( 'session_token_hash' => $session_token_hash ),
            array( '%s', '%s' ),
            array( '%s' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( false === $updated ) {
            Guard_Dog()->debug->warning( 'Session Manager: Heartbeat update failed', 'session_manager', array(
                'token_hash' => substr( $session_token_hash, 0, 12 ) . '...',
                'db_error'   => $wpdb->last_error,
            ) );
            return;
        }

        if ( 0 === (int) $updated ) {
            return;
        }

        set_transient( $transient_key, true, self::HEARTBEAT_INTERVAL );
    }

    /**
     * Enforce session limit by terminating oldest session if needed.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     */
    private function enforce_session_limit( $user_id, $protected_token_hash = null ) {
        $max_sessions = $this->get_max_sessions_per_user();
        $sessions = $this->get_user_sessions( $user_id );

        // Account for the new session being created
        while ( count( $sessions ) >= $max_sessions ) {
            // Find oldest non-compromised session
            $oldest_session = null;
            foreach ( array_reverse( $sessions ) as $session ) {
                if ( $session->is_compromised ) {
                    continue;
                }

                if ( $protected_token_hash && hash_equals( (string) $protected_token_hash, (string) $session->session_token_hash ) ) {
                    continue;
                }

                $oldest_session = $session;
                break;
            }

            if ( $oldest_session ) {
                $this->terminate_session( $oldest_session->session_id, 'session_limit' );

                if ( $this->activity_log ) {
                    $this->activity_log->add_log_entry( 'session_terminated_limit', array(
                        'user_id'         => $user_id,
                        'session_id'      => $oldest_session->session_id,
                        'max_sessions'    => $max_sessions,
                        'active_sessions' => count( $sessions ),
                    ) );
                }

                // Remove from array for next iteration
                $sessions = array_filter( $sessions, function( $s ) use ( $oldest_session ) {
                    return $s->session_id !== $oldest_session->session_id;
                } );
            } else {
                break; // No suitable session to terminate
            }
        }
    }

    //--------------------------------------------------------------------------
    // Cleanup
    //--------------------------------------------------------------------------

    /**
     * Retention period in days for expired sessions before garbage collection.
     *
     * Expired sessions are retained in the database so that validate_current_session()
     * can detect them and force logout users whose sessions have timed out. Without
     * this buffer, the cron would delete the evidence before the user's next visit,
     * and the backfill logic would create a fresh session — defeating the timeout.
     *
     * @var int
     */
    const CLEANUP_RETENTION_DAYS = 7;

    /**
     * Clean up expired sessions (cron job).
     *
     * Only removes sessions that have been expired for longer than CLEANUP_RETENTION_DAYS.
     * Recently-expired sessions are retained so that validate_current_session() can
     * detect them and enforce session timeouts on the user's next visit.
     *
     * @since 1.9.1
     */
    public function cleanup_expired_sessions() {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_sessions_table_name();
        $escaped_table = esc_sql( $table_name );
        $retention_cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::CLEANUP_RETENTION_DAYS * DAY_IN_SECONDS ) );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        // Direct database query is necessary for cleanup operations
        // Table name is validated by Guard_Dog_DB
        $deleted = $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM $escaped_table WHERE expires_at < %s",
                $retention_cutoff
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        if ( $deleted > 0 ) {
            Guard_Dog()->debug->info( 'Session Manager: Cleaned up expired sessions', 'session_manager', array(
                'count' => $deleted,
            ) );
        }
    }

    //--------------------------------------------------------------------------
    // AJAX Handlers
    //--------------------------------------------------------------------------

    /**
     * AJAX handler to terminate a specific session.
     *
     * @since 1.9.1
     */
    public function ajax_terminate_session() {
        check_ajax_referer( 'guard_dog_session_nonce', 'nonce' );

        $session_id = isset( $_POST['session_id'] ) ? absint( $_POST['session_id'] ) : 0;

        if ( ! $session_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid session ID.', 'guard-dog' ) ) );
        }

        $session = $this->get_session( $session_id );
        if ( ! $session ) {
            wp_send_json_error( array( 'message' => __( 'Invalid session ID.', 'guard-dog' ) ) );
        }

        // Admins can terminate any session; users can terminate only their own.
        $can_terminate = current_user_can( 'manage_options' )
            || ( (int) $session->user_id === get_current_user_id() );

        if ( ! $can_terminate ) {
            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'guard-dog' ) ) );
        }

        $reason = current_user_can( 'manage_options' ) ? 'admin_action' : 'user_self';

        if ( $this->terminate_session( $session_id, $reason ) ) {
            wp_send_json_success( array( 'message' => __( 'Session terminated successfully.', 'guard-dog' ) ) );
        } else {
            wp_send_json_error( array( 'message' => __( 'Failed to terminate session.', 'guard-dog' ) ) );
        }
    }

    /**
     * AJAX handler to terminate all sessions for a user.
     *
     * @since 1.9.1
     */
    public function ajax_terminate_all_sessions() {
        check_ajax_referer( 'guard_dog_session_nonce', 'nonce' );

        $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
        $raw_include     = isset( $_POST['include_current'] ) ? sanitize_text_field( wp_unslash( $_POST['include_current'] ) ) : '';
        $include_current = in_array( $raw_include, array( 'true', '1' ), true );

        // Check permissions: admins can terminate any user's sessions, users can terminate their own
        if ( ! current_user_can( 'manage_options' ) && get_current_user_id() !== $user_id ) {
            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'guard-dog' ) ) );
        }

        if ( ! $user_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid user ID.', 'guard-dog' ) ) );
        }

        $count = $this->terminate_all_user_sessions( $user_id, $include_current );

        wp_send_json_success( array(
            'message' => sprintf(
                /* translators: %d: number of sessions terminated */
                _n( '%d session terminated.', '%d sessions terminated.', $count, 'guard-dog' ),
                $count
            ),
            'count' => $count,
        ) );
    }

    /**
     * AJAX handler to get sessions for a user.
     *
     * @since 1.9.1
     */
    public function ajax_get_user_sessions() {
        check_ajax_referer( 'guard_dog_session_nonce', 'nonce' );

        $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : get_current_user_id();

        // Check permissions
        if ( ! current_user_can( 'manage_options' ) && get_current_user_id() !== $user_id ) {
            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'guard-dog' ) ) );
        }

        $sessions = $this->get_user_sessions( $user_id );
        $current_token_hash = null;

        if ( get_current_user_id() === $user_id ) {
            $session_token = $this->get_current_session_token();
            if ( $session_token ) {
                $current_token_hash = hash( 'sha256', $session_token );
            }
        }

        $formatted_sessions = array();
        foreach ( $sessions as $session ) {
            $formatted_sessions[] = array(
                'session_id'    => $session->session_id,
                'ip_address'    => $session->ip_address,
                'location'      => $this->format_location( $session->geo_country, $session->geo_city ),
                'device'        => $this->parse_user_agent( $session->user_agent ),
                'last_activity' => human_time_diff( strtotime( $session->last_activity ), current_time( 'timestamp', true ) ) . ' ' . __( 'ago', 'guard-dog' ),
                'created_at'    => human_time_diff( strtotime( $session->created_at ), current_time( 'timestamp', true ) ) . ' ' . __( 'ago', 'guard-dog' ),
                'is_current'    => ( $current_token_hash && $session->session_token_hash === $current_token_hash ),
                'is_compromised' => (bool) $session->is_compromised,
                'compromised_reason' => $session->compromised_reason,
            );
        }

        wp_send_json_success( array( 'sessions' => $formatted_sessions ) );
    }

    //--------------------------------------------------------------------------
    // User Profile Section
    //--------------------------------------------------------------------------

    /**
     * Render sessions section on user profile page.
     *
     * @since 1.9.1
     * @param WP_User $user The user being edited.
     */
    public function render_user_sessions_section( $user ) {
        $sessions = $this->get_user_sessions( $user->ID );
        $current_token_hash = null;

        if ( get_current_user_id() === $user->ID ) {
            $session_token = $this->get_current_session_token();
            if ( $session_token ) {
                $current_token_hash = hash( 'sha256', $session_token );
            }
        }

        ?>
        <h2><?php esc_html_e( 'Active Sessions', 'guard-dog' ); ?></h2>
        <p class="description">
            <?php esc_html_e( 'This list shows all active sessions for this account. You can terminate sessions to log out from other devices.', 'guard-dog' ); ?>
        </p>

        <?php if ( empty( $sessions ) ) : ?>
            <p><?php esc_html_e( 'No active sessions found.', 'guard-dog' ); ?></p>
        <?php else : ?>
            <table class="widefat fixed striped" id="guard-dog-sessions-table">
                <thead>
                    <tr>
                        <th><?php esc_html_e( 'Device / Browser', 'guard-dog' ); ?></th>
                        <th><?php esc_html_e( 'Location', 'guard-dog' ); ?></th>
                        <th><?php esc_html_e( 'IP Address', 'guard-dog' ); ?></th>
                        <th><?php esc_html_e( 'Last Activity', 'guard-dog' ); ?></th>
                        <th><?php esc_html_e( 'Status', 'guard-dog' ); ?></th>
                        <th><?php esc_html_e( 'Actions', 'guard-dog' ); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ( $sessions as $session ) :
                        $is_current = ( $current_token_hash && $session->session_token_hash === $current_token_hash );
                        $device_info = $this->parse_user_agent( $session->user_agent );
                    ?>
                        <tr class="<?php echo $session->is_compromised ? 'guard-dog-compromised-session' : ''; ?>">
                            <td>
                                <?php echo esc_html( $device_info ); ?>
                                <?php if ( $is_current ) : ?>
                                    <span class="guard-dog-current-session-badge"><?php esc_html_e( 'Current', 'guard-dog' ); ?></span>
                                <?php endif; ?>
                            </td>
                            <td><?php echo esc_html( $this->format_location( $session->geo_country, $session->geo_city ) ); ?></td>
                            <td><?php echo esc_html( $session->ip_address ); ?></td>
                            <td>
                                <?php
                                echo esc_html(
                                    human_time_diff( strtotime( $session->last_activity ), current_time( 'timestamp', true ) )
                                    . ' ' . __( 'ago', 'guard-dog' )
                                );
                                ?>
                            </td>
                            <td>
                                <?php if ( $session->is_compromised ) : ?>
                                    <span class="guard-dog-status-warning" title="<?php echo esc_attr( $session->compromised_reason ); ?>">
                                        <?php esc_html_e( 'Suspicious', 'guard-dog' ); ?>
                                    </span>
                                <?php else : ?>
                                    <span class="guard-dog-status-ok"><?php esc_html_e( 'Active', 'guard-dog' ); ?></span>
                                <?php endif; ?>
                            </td>
                            <td>
                                <?php if ( ! $is_current ) : ?>
                                    <button type="button"
                                            class="button button-small guard-dog-terminate-session"
                                            data-session-id="<?php echo esc_attr( $session->session_id ); ?>"
                                            data-nonce="<?php echo esc_attr( wp_create_nonce( 'guard_dog_session_nonce' ) ); ?>">
                                        <?php esc_html_e( 'Terminate', 'guard-dog' ); ?>
                                    </button>
                                <?php else : ?>
                                    <span class="description"><?php esc_html_e( 'This session', 'guard-dog' ); ?></span>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>

            <?php if ( count( $sessions ) > 1 ) : ?>
                <p style="margin-top: 15px;">
                    <button type="button"
                            class="button guard-dog-terminate-all-sessions"
                            data-user-id="<?php echo esc_attr( $user->ID ); ?>"
                            data-nonce="<?php echo esc_attr( wp_create_nonce( 'guard_dog_session_nonce' ) ); ?>">
                        <?php esc_html_e( 'Terminate All Other Sessions', 'guard-dog' ); ?>
                    </button>
                </p>
            <?php endif; ?>
	        <?php endif;
	    }

	    /**
	     * Get active-session management markup for frontend account surfaces.
	     *
	     * @since 1.9.40
	     * @param WP_User $user User whose sessions are being managed.
	     * @param array   $args Optional rendering arguments.
	     * @return string
	     */
	    public function get_frontend_management_markup( $user, $args = array() ) {
	        if ( ! $user instanceof WP_User || ! is_user_logged_in() ) {
	            return '';
	        }

	        $user_id = (int) $user->ID;
	        if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
	            return '';
	        }

	        $sessions = $this->get_user_sessions( $user_id );
	        $current_token_hash = null;

	        if ( get_current_user_id() === $user_id ) {
	            $session_token = $this->get_current_session_token();
	            if ( $session_token ) {
	                $current_token_hash = hash( 'sha256', $session_token );
	            }
	        }

	        $formatted_sessions = array();
	        foreach ( $sessions as $session ) {
	            $formatted_sessions[] = array(
	                'session_id'         => absint( $session->session_id ),
	                'device'             => $this->parse_user_agent( $session->user_agent ),
	                'location'           => $this->format_location( $session->geo_country, $session->geo_city ),
	                'ip_address'         => $session->ip_address,
	                'last_activity'      => human_time_diff( strtotime( $session->last_activity ), current_time( 'timestamp', true ) ) . ' ' . __( 'ago', 'guard-dog' ),
	                'created_at'         => human_time_diff( strtotime( $session->created_at ), current_time( 'timestamp', true ) ) . ' ' . __( 'ago', 'guard-dog' ),
	                'is_current'         => ( $current_token_hash && $session->session_token_hash === $current_token_hash ),
	                'is_compromised'     => (bool) $session->is_compromised,
	                'compromised_reason' => $session->compromised_reason,
	            );
	        }

	        return Guard_Dog_View_Loader::load_partial(
	            'frontend-sessions',
	            array(
	                'user'     => $user,
	                'sessions' => $formatted_sessions,
	                'nonce'    => wp_create_nonce( 'guard_dog_session_nonce' ),
	                'context'  => isset( $args['context'] ) ? sanitize_key( $args['context'] ) : 'shortcode',
	                'style'    => isset( $args['style'] ) ? sanitize_key( $args['style'] ) : 'default',
	            )
	        );
	    }

	    /**
	     * Enqueue frontend assets for session management controls.
	     *
	     * @since 1.9.40
	     */
	    public function enqueue_frontend_assets() {
	        wp_enqueue_style(
	            'guard-dog-frontend-sessions',
	            GUARD_DOG_URL . 'assets/css/guard-dog-frontend-sessions.css',
	            array(),
	            GUARD_DOG_VERSION
	        );

	        wp_enqueue_script(
	            'guard-dog-session-manager',
	            GUARD_DOG_URL . 'assets/js/guard-dog-session-manager.js',
	            array( 'jquery' ),
	            GUARD_DOG_VERSION,
	            true
	        );

	        wp_localize_script( 'guard-dog-session-manager', 'guardDogSessions', $this->get_session_manager_script_data() );
	    }

	    /**
	     * Enqueue admin scripts for session management.
     *
     * @since 1.9.1
     * @param string $hook The current admin page hook.
     */
    public function enqueue_admin_scripts( $hook ) {
        // Load on profile, user edit, and sessions admin pages.
        $allowed_hooks = array(
            'profile.php',
            'user-edit.php',
            'guard-dog_page_guard-dog-sessions',
            'guard-dog_page_guard-dog-users-sessions',
        );
        if ( ! in_array( $hook, $allowed_hooks, true ) ) {
            return;
        }

        wp_enqueue_script(
            'guard-dog-session-manager',
            GUARD_DOG_URL . 'assets/js/guard-dog-session-manager.js',
            array( 'jquery' ),
            GUARD_DOG_VERSION,
            true
        );

	        wp_localize_script( 'guard-dog-session-manager', 'guardDogSessions', $this->get_session_manager_script_data() );

        // Inline styles for session table
        wp_add_inline_style( 'wp-admin', '
            .guard-dog-current-session-badge {
                background: #00a32a;
                color: #fff;
                padding: 2px 6px;
                border-radius: 3px;
                font-size: 11px;
                margin-left: 5px;
            }
            .guard-dog-compromised-session {
                background-color: #fcf0f1 !important;
            }
            .guard-dog-status-warning {
                color: #d63638;
                font-weight: 600;
            }
            .guard-dog-status-ok {
                color: #00a32a;
            }
        ' );
    }

    //--------------------------------------------------------------------------
	    // Helper Methods
	    //--------------------------------------------------------------------------

	    /**
	     * Get localized script data for session management controls.
	     *
	     * @since 1.9.40
	     * @return array
	     */
	    private function get_session_manager_script_data() {
	        return array(
	            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
	            'nonce'   => wp_create_nonce( 'guard_dog_session_nonce' ),
	            'strings' => array(
	                'confirmTerminate'    => __( 'Are you sure you want to terminate this session?', 'guard-dog' ),
	                'confirmTerminateAll' => __( 'Are you sure you want to terminate all other sessions?', 'guard-dog' ),
	                'terminating'         => __( 'Terminating...', 'guard-dog' ),
	                'terminated'          => __( 'Terminated', 'guard-dog' ),
	                'noSessions'          => __( 'No active sessions found.', 'guard-dog' ),
	                'error'               => __( 'An error occurred. Please try again.', 'guard-dog' ),
	            ),
	        );
	    }

	    /**
	     * Determine whether the current request is an admin page (not AJAX/REST/cron).
     *
     * WordPress's is_admin() returns true for admin-ajax.php, which runs in the
     * admin context even for frontend AJAX requests. For session expiration we
     * need to distinguish between an actual wp-admin page load (redirect to
     * login) and AJAX/REST/cron requests (silent expire or JSON error).
     *
     * @since 1.9.34
     * @return bool True if this is a regular admin page request.
     */
    private function is_admin_page_request() {
        if ( wp_doing_ajax() || wp_doing_cron() ) {
            return false;
        }
        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
            return false;
        }
        return is_admin();
    }

    /**
     * Get the current WordPress session token.
     *
     * @since 1.9.1
     * @return string|null The session token or null if not available.
     */
    protected function get_current_session_token() {
        $cookie = wp_parse_auth_cookie( '', 'logged_in' );

        if ( ! $cookie || empty( $cookie['token'] ) ) {
            return null;
        }

        return $cookie['token'];
    }

    /**
     * Get a session by its token hash.
     *
     * @since 1.9.1
     * @param string $session_token_hash The hashed session token.
     * @return object|null Session data or null.
     */
    private function get_session_by_hash( $session_token_hash ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_sessions_table_name();
        $escaped_table = esc_sql( $table_name );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $session = $wpdb->get_row(
            $wpdb->prepare( "SELECT * FROM $escaped_table WHERE session_token_hash = %s", $session_token_hash )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $session;
    }

    /**
     * Fetch WordPress session_tokens directly from usermeta.
     *
     * This bypasses user-meta object cache so cleanup/anomaly checks don't make
     * destructive decisions based on stale cached meta.
     *
     * @since 1.9.35
     * @param int $user_id The user ID.
     * @return array Session token map keyed by token hash.
     */
    private function get_authoritative_wp_session_tokens( $user_id ) {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
        $raw_tokens = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id DESC LIMIT 1",
                $user_id,
                'session_tokens'
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery

        if ( null === $raw_tokens ) {
            return array();
        }

        $tokens = maybe_unserialize( $raw_tokens );
        return is_array( $tokens ) ? $tokens : array();
    }

    /**
     * Acquire a best-effort per-user DB advisory lock.
     *
     * @since 1.9.35
     * @param int $user_id The user ID.
     * @return string|null Lock key when acquired; null when not acquired.
     */
    private function acquire_user_session_lock( $user_id ) {
        global $wpdb;

        $lock_key = substr( 'guard_dog_session_user_' . (int) $user_id, 0, 64 );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
        $acquired = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT GET_LOCK(%s, 2)',
                $lock_key
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery

        if ( '1' !== (string) $acquired ) {
            Guard_Dog()->debug->warning( 'Session Manager: Could not acquire user session lock', 'session_manager', array(
                'user_id' => (int) $user_id,
            ) );
            return null;
        }

        return $lock_key;
    }

    /**
     * Release a previously-acquired DB advisory lock.
     *
     * @since 1.9.35
     * @param string|null $lock_key Lock key returned by acquire_user_session_lock().
     */
    private function release_user_session_lock( $lock_key ) {
        global $wpdb;

        if ( empty( $lock_key ) ) {
            return;
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->get_var(
            $wpdb->prepare(
                'SELECT RELEASE_LOCK(%s)',
                $lock_key
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
    }

    /**
     * Destroy a WordPress session token for a user.
     *
     * WordPress stores session tokens in user meta, indexed by the SHA-256 hash (verifier).
     * Guard Dog also stores the SHA-256 hash, so we can match and destroy directly.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @param string $session_token_hash The hashed token to destroy (SHA-256).
     */
    private function destroy_wp_session_token( $user_id, $session_token_hash ) {
        // IMPORTANT: We must read session_tokens directly from user meta, NOT via WP_Session_Tokens::get_all()
        // because get_all() uses array_values() which strips the hash keys and returns numeric indices!
        $sessions = get_user_meta( $user_id, 'session_tokens', true );

        if ( ! is_array( $sessions ) ) {
            $sessions = array();
        }

        // WordPress verifiers are SHA-256 hashes of the token - same as what we store
        // Find and remove the matching session from WordPress's session store
        if ( isset( $sessions[ $session_token_hash ] ) ) {
            unset( $sessions[ $session_token_hash ] );

            // Update the user meta with the remaining sessions
            // This directly updates WordPress's session storage
            if ( empty( $sessions ) ) {
                delete_user_meta( $user_id, 'session_tokens' );
            } else {
                update_user_meta( $user_id, 'session_tokens', $sessions );
            }

            Guard_Dog()->debug->info( 'Session Manager: Destroyed WordPress session token for user ' . $user_id, 'session_manager' );
        } else {
            Guard_Dog()->debug->warning( 'Session Manager: WordPress session token not found for hash ' . substr( $session_token_hash, 0, 8 ) . '...', 'session_manager' );
        }
    }

    /**
     * Force logout a user.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @param string $reason Reason for the forced logout.
     */
    private function force_logout( $user_id, $reason ) {

		// Send no-cache headers BEFORE any output or redirect. This method runs at
		// 'init' priority 1, which means it exits before wp-admin/admin.php sends its
		// own nocache_headers(). Without this, reverse proxies (Varnish, Nginx FastCGI,
		// Cloudflare) can cache the 302 redirect response, causing every subsequent
		// request to the same URL to be redirected — making sessions appear to expire
		// on every page change.
		nocache_headers();

		// Destroy the Guard Dog session record FIRST, while the session token is still
		// available from the cookie and get_current_user_id() is still valid. If we wait
		// for the clear_auth_cookie hook (fired by wp_clear_auth_cookie below), the WP
		// session has already been destroyed by wp_destroy_current_session(), which can
		// cause get_current_user_id() to return 0 in some environments — leaving the
		// Guard Dog session record orphaned in the database.
		$session_token = $this->get_current_session_token();
		if ( $session_token ) {
			$session_token_hash = hash( 'sha256', $session_token );
			$this->destroy_session_by_hash( $session_token_hash, $user_id, $reason );
		}

	    wp_destroy_current_session();
        wp_clear_auth_cookie();

        if ( $this->activity_log ) {
            $this->activity_log->add_log_entry( 'session_expired', array(
                'user_id' => $user_id,
                'details' => array(
                    'reason'  => $reason,
                    'trigger' => $reason,
                    'context' => $this->is_admin_page_request() ? 'admin_page' : 'other',
                    'is_ajax' => wp_doing_ajax(),
                    'is_rest' => defined( 'REST_REQUEST' ) && REST_REQUEST,
                ),
            ) );
        }

        // Redirect to login with error message
        $login_url = wp_login_url();
        $login_url = add_query_arg( 'guard_dog_session_error', $reason, $login_url );

        if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
            wp_send_json_error( array( 'message' => 'session_expired', 'redirect' => $login_url ) );
        }

        wp_safe_redirect( $login_url );
        exit;
    }

    /**
     * Silently expire a session without redirecting the user.
     *
     * Destroys the Guard Dog session record, clears the auth cookie, and writes
     * an activity log entry. Unlike force_logout(), no browser redirect occurs —
     * the user continues as a logged-out visitor on the current page load.
     *
     * @since 1.9.34
     * @param int $user_id The user ID.
     */
    private function silent_expire( $user_id ) {
        nocache_headers();

        $session_token = $this->get_current_session_token();
        if ( $session_token ) {
            $session_token_hash = hash( 'sha256', $session_token );
            $this->destroy_session_by_hash( $session_token_hash, $user_id, 'session_expired' );
        }

        // Destroy the WP session BEFORE logging — mirrors force_logout() ordering.
        // If logging fails (OOM, SIGTERM), the user is still securely logged out
        // rather than remaining authenticated but invisible to Guard Dog monitoring.
        wp_destroy_current_session();
        wp_clear_auth_cookie();

        if ( $this->activity_log ) {
            $this->activity_log->add_log_entry( 'session_expired', array(
                'user_id' => $user_id,
                'details' => array(
                    'reason'  => 'session_expired_frontend',
                    'trigger' => 'session_expired',
                    'context' => 'frontend_silent',
                    'is_ajax' => wp_doing_ajax(),
                    'is_rest' => defined( 'REST_REQUEST' ) && REST_REQUEST,
                ),
            ) );
        }
    }

    /**
     * Get client IP address.
     *
     * Security note: Only trusts REMOTE_ADDR by default to prevent IP spoofing.
     * For sites behind load balancers/proxies, use Guard_Dog_Utils::get_client_ip()
     * which can be configured with trusted proxy settings.
     *
     * @since 1.9.1
     * @return string The client IP address.
     */
    private function get_client_ip() {
        // Prefer the main utility class which handles proxy configuration.
        if ( class_exists( 'Guard_Dog_Utils' ) && method_exists( 'Guard_Dog_Utils', 'get_client_ip' ) ) {
            return Guard_Dog_Utils::get_client_ip();
        }

        // Secure fallback - ONLY trust REMOTE_ADDR to prevent IP spoofing.
        // HTTP_CLIENT_IP and HTTP_X_FORWARDED_FOR can be set by any client.
        if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
            $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
            // Validate it's actually an IP address.
            if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
                return $ip;
            }
        }

        return '';
    }

    /**
     * Generate a device fingerprint from user agent and IP.
     *
     * @since 1.9.1
     * @param string $user_agent The user agent string.
     * @param string $ip_address The IP address.
     * @return string The fingerprint hash.
     */
    private function generate_device_fingerprint( $user_agent, $ip_address ) {
        return hash( 'sha256', $user_agent . '|' . $ip_address );
    }

    /**
     * Get geo data for an IP address with fallback to cache.
     *
     * Uses the IP reputation checker's fallback mechanism to retrieve geo data,
     * falling back to cached data if the API is unavailable.
     *
     * @since 1.9.1
     * @param string $ip_address The IP address.
     * @return array Array with 'country' and 'city' keys.
     */
    private function get_geo_data( $ip_address ) {
        $geo_data = array(
            'country' => null,
            'city'    => null,
        );

        if ( ! $this->ip_reputation_checker ) {
            return $geo_data;
        }

        // Use the fallback method which tries API first, then falls back to cache
        $result = $this->ip_reputation_checker->get_geo_data_with_fallback( $ip_address );

        if ( $result['success'] && ! empty( $result['geo_data'] ) ) {
            $geo = $result['geo_data'];

            if ( ! empty( $geo['country_code'] ) ) {
                $geo_data['country'] = $geo['country_code'];
            }
            if ( ! empty( $geo['city'] ) ) {
                $geo_data['city'] = $geo['city'];
            }
        }

        return $geo_data;
    }

    /**
     * Format location string from country and city.
     *
     * @since 1.9.1
     * @param string|null $country The country code.
     * @param string|null $city The city name.
     * @return string Formatted location string.
     */
    private function format_location( $country, $city ) {
        if ( $city && $country ) {
            return $city . ', ' . $country;
        } elseif ( $country ) {
            return $country;
        } elseif ( $city ) {
            return $city;
        }

        return __( 'Unknown', 'guard-dog' );
    }

    /**
     * Parse user agent string to get device/browser info.
     *
     * @since 1.9.1
     * @param string $user_agent The user agent string.
     * @return string Parsed device/browser description.
     */
    private function parse_user_agent( $user_agent ) {
        if ( empty( $user_agent ) ) {
            return __( 'Unknown device', 'guard-dog' );
        }

        $browser = 'Unknown Browser';
        $os = 'Unknown OS';

        // Detect browser
        if ( preg_match( '/Firefox\/[\d.]+/i', $user_agent ) ) {
            $browser = 'Firefox';
        } elseif ( preg_match( '/Edg\/[\d.]+/i', $user_agent ) ) {
            $browser = 'Edge';
        } elseif ( preg_match( '/Chrome\/[\d.]+/i', $user_agent ) && ! preg_match( '/Edg/i', $user_agent ) ) {
            $browser = 'Chrome';
        } elseif ( preg_match( '/Safari\/[\d.]+/i', $user_agent ) && ! preg_match( '/Chrome/i', $user_agent ) ) {
            $browser = 'Safari';
        } elseif ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
            $browser = 'Internet Explorer';
        }

        // Detect OS
        if ( preg_match( '/Windows NT 10/i', $user_agent ) ) {
            $os = 'Windows';
        } elseif ( preg_match( '/Windows/i', $user_agent ) ) {
            $os = 'Windows';
        } elseif ( preg_match( '/Mac OS X/i', $user_agent ) ) {
            $os = 'macOS';
        } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
            $os = 'Linux';
        } elseif ( preg_match( '/iPhone|iPad/i', $user_agent ) ) {
            $os = 'iOS';
        } elseif ( preg_match( '/Android/i', $user_agent ) ) {
            $os = 'Android';
        }

        return $browser . ' on ' . $os;
    }

    /**
     * Send notifications for suspicious activity.
     * Uses View Loader and Email Template Customizer when available; falls back to plugin default views.
     * Sends via Guard_Dog_Email_Provider when configured, otherwise wp_mail.
     *
     * @since 1.9.1
     * @param int    $user_id The affected user ID.
     * @param string $type    The type of suspicious activity (ip_shift, geo_anomaly, ip_reputation, etc.).
     * @param array  $data    Additional data about the activity.
     */
    private function send_suspicious_activity_notification( $user_id, $type, $data ) {
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return;
        }

        $admin_email = get_option( 'admin_email' );
        $site_name   = get_bloginfo( 'name' );

        // Build details string (same as previous inline messages) for admin template
        if ( 'ip_shift' === $type ) {
            $details = sprintf(
                /* translators: 1: Username, 2: Old IP, 3: Old country, 4: New IP, 5: New country */
                __( "A session for user '%1\$s' has changed IP addresses significantly:\n\nOld IP: %2\$s (%3\$s)\nNew IP: %4\$s (%5\$s)\n\nThis may indicate a session hijacking attempt.", 'guard-dog' ),
                $user->user_login,
                $data['old_ip'],
                $data['old_country'],
                $data['new_ip'],
                $data['new_country']
            );
        } elseif ( 'geo_anomaly' === $type ) {
            $details = sprintf(
                /* translators: 1: Username, 2: List of countries */
                __( "User '%1\$s' has concurrent active sessions from multiple countries:\n\n%2\$s\n\nThis may indicate compromised credentials.", 'guard-dog' ),
                $user->user_login,
                implode( ', ', $data['countries'] )
            );
        } elseif ( 'ip_reputation' === $type ) {
            $details = sprintf(
                /* translators: 1: Username, 2: IP address, 3: Country, 4: Abuse confidence score */
                __( "A session for user '%1\$s' was terminated due to malicious IP activity.\n\nIP Address: %2\$s\nCountry: %3\$s\nAbuse Confidence: %4\$d%%\n\nThe session has been automatically terminated for security.", 'guard-dog' ),
                $user->user_login,
                $data['ip'],
                ! empty( $data['country'] ) ? $data['country'] : __( 'Unknown', 'guard-dog' ),
                $data['abuse_confidence']
            );
        } else {
            $details = sprintf(
                /* translators: 1: Username, 2: Activity type */
                __( "Suspicious activity detected for user '%1\$s': %2\$s", 'guard-dog' ),
                $user->user_login,
                $type
            );
        }

        $template_args_admin = array(
            'user'      => $user,
            'type'      => $type,
            'details'   => $details,
            'site_name' => $site_name,
        );

        $html_admin  = Guard_Dog_View_Loader::load_email_template( 'suspicious-activity-admin', $template_args_admin );
        $plain_admin = Guard_Dog_View_Loader::load_email_template( 'suspicious-activity-admin-plain', $template_args_admin );

        $subject = null;
        if ( class_exists( 'Guard_Dog_Email_Template_Customizer' ) ) {
            $customizer_admin = new Guard_Dog_Email_Template_Customizer( array( 'template_type' => 'suspicious-activity-admin' ) );
            $subject          = $customizer_admin->get_email_subject( array_merge( $template_args_admin, array( 'username' => $user->user_login ) ) );
        }
        if ( $subject === null || $subject === '' ) {
            $subject = sprintf(
                /* translators: 1: Site name, 2: Activity type */
                __( '[%1$s] Suspicious Session Activity Detected: %2$s', 'guard-dog' ),
                $site_name,
                $type
            );
        }

        $this->send_suspicious_activity_email( $admin_email, $subject, $html_admin, $plain_admin );

        // Optionally send to user
        if ( get_option( 'guard_dog_sessions_notify_user', false ) ) {
            $template_args_user = array(
                'user'      => $user,
                'site_name' => $site_name,
                'name'      => $user->display_name,
            );

            $html_user  = Guard_Dog_View_Loader::load_email_template( 'suspicious-activity-user', $template_args_user );
            $plain_user = Guard_Dog_View_Loader::load_email_template( 'suspicious-activity-user-plain', $template_args_user );

            $user_subject = null;
            if ( class_exists( 'Guard_Dog_Email_Template_Customizer' ) ) {
                $customizer_user = new Guard_Dog_Email_Template_Customizer( array( 'template_type' => 'suspicious-activity-user' ) );
                $user_subject    = $customizer_user->get_email_subject( $template_args_user );
            }
            if ( $user_subject === null || $user_subject === '' ) {
                $user_subject = sprintf(
                    /* translators: Site name */
                    __( '[%s] Security Alert: Unusual Session Activity', 'guard-dog' ),
                    $site_name
                );
            }

            $this->send_suspicious_activity_email( $user->user_email, $user_subject, $html_user, $plain_user );
        }
    }

    /**
     * Send a single suspicious activity email using Guard_Dog_Email_Provider when configured, else wp_mail.
     *
     * @since 1.9.1
     * @param string $to      Recipient email.
     * @param string $subject Subject line.
     * @param string $html    HTML body (can be empty).
     * @param string $plain   Plain text body.
     */
    private function send_suspicious_activity_email( $to, $subject, $html, $plain ) {
        if ( class_exists( 'Guard_Dog_Email_Provider' ) ) {
            $provider = new Guard_Dog_Email_Provider();
            if ( $provider->is_configured() ) {
                $provider->send_email( $to, $subject, $html ? $html : $plain, $plain );
                return;
            }
        }

        $body = $html ? $html : $plain;
        $headers = array();
        if ( $html ) {
            $headers[] = 'Content-Type: text/html; charset=UTF-8';
        }
        wp_mail( $to, $subject, $body, $headers );
    }

} // End class Guard_Dog_Session_Manager
