<?php
/**
 * Temporary Login — token links, session scoping, optional notifications.
 *
 * @package TemporaryLogin
 */

namespace TemporaryLogin;

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

require_once __DIR__ . '/FeatureGate.php';
require_once __DIR__ . '/includes/premium.php';
require_once __DIR__ . '/includes/class-temporary-login-email.php';

use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\OptionStore;

/**
 * Temporary Login Module Class
 *
 * @since 1.0.0
 */
class Module extends AbstractModule {

	private const MODULE_ID = 'temporary-login';
	private const MODULE_VERSION = '1.3.11';
	private const LOG_RETENTION_DAYS = 90;
	private const TOKEN_PARAM = 'tl_token';
	private const DB_TABLE_NAME = 'tl_temporary_logins';
	private const DB_LOG_TABLE_NAME = 'tl_activity_logs';
	/**
	 * Legacy global session marker (deprecated). Sessions are per-browser via {@see SESSION_COOKIE_NAME}.
	 *
	 * @deprecated Stored only for cleanup; do not write on new logins.
	 */
	private const USER_META_SESSION_LOGIN = '_wpps_tl_session_login';
	/** Server-side map of hashed WP session tokens → temp-login row. */
	private const USER_META_BOUND_SESSIONS = '_wpps_tl_bound_sessions';
	/** Per-browser temp session cookie (login_id.user_id.hmac). */
	private const SESSION_COOKIE_NAME = 'wpps_tl_session';
	/** Set for users created via temporary-login email flow — password login / reset disabled */
	private const USER_META_PASSWORDLESS_ACCOUNT = '_wpps_tl_passwordless_only';
	private const TOKEN_RATE_TRANSIENT_PREFIX = 'tl_tok_fail_';
	private const DB_READY_OPTION = 'wpps_tl_db_ready';
	/** Encrypted admin-only plaintext for Copy (login hash stays HMAC-only). */
	private const COPY_REVEAL_OPTION_PREFIX = 'wpps_tl_reveal_';
	private const COPY_REVEAL_ENC_PREFIX = 'v1:';

	/**
	 * JSON object stored in allowed_capabilities (plugin/theme grants for the session).
	 *
	 * @see self::PERM_INSTALL_PLUGINS Keys written by encodeLoginPermissionsFromArgs().
	 */
	private const PERM_INSTALL_PLUGINS = 'install_plugins';
	private const PERM_DELETE_PLUGINS  = 'delete_plugins';
	private const PERM_INSTALL_THEMES  = 'install_themes';
	private const PERM_DELETE_THEMES   = 'delete_themes';

	/** Admin screens never available during a temporary session. */
	private const ALWAYS_BLOCKED_ADMIN_SCREENS = [
		'plugin-editor.php',
		'theme-editor.php',
		'update-core.php',
		'import.php',
		'export.php',
		'site-health.php',
	];

	/**
	 * Owner-only WP PowerSuite pages. Temp admins keep Modules, Settings,
	 * and module screens; they cannot manage invites or the license.
	 */
	private const BLOCKED_POWERSUITE_PAGES = [
		'powersuite-temporary-login',
		'powersuite-license',
	];

	/**
	 * Membership / takeover options. Other Settings → General fields stay writable.
	 *
	 * @var list<string>
	 */
	private const BLOCKED_MEMBERSHIP_OPTIONS = [
		'users_can_register',
		'default_role',
		'admin_email',
		'new_admin_email',
	];

	private ?object $session_login_cache = null;

	/** @var array<string, bool>|null Role capabilities for the active temp session. */
	private ?array $session_role_caps_cache = null;

	/** @var array<string, object|null> Usable-login lookup for the current request, keyed by plain token. */
	private array $usable_login_memo = [];

	/** Session token captured from set_logged_in_cookie during token login. */
	private string $pending_bind_token = '';

	/** Login row waiting to be attached to the minted WP session. */
	private ?object $pending_bind_login = null;

	/** Once a temp bind/cookie is invalid this request, stay fail-closed. */
	private bool $fail_closed_session = false;

	/**
	 * Get Module ID
	 *
	 * @since 1.0.0
	 * @return string Module ID.
	 */
	public function getId(): string {
		return self::MODULE_ID;
	}

	/**
	 * Get Module Name
	 *
	 * @since 1.0.0
	 * @return string Module name.
	 */
	public function getName(): string {
		return __( 'Temporary Login', 'wppowersuite-lite' );
	}

	/**
	 * Get Module Version
	 *
	 * @since 1.0.0
	 * @return string Module version.
	 */
	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	/**
	 * Get Module Description
	 *
	 * @since 1.0.0
	 * @return string Module description.
	 */
	public function getDescription(): string {
		return __( 'Expiring, one-click login links for vendors or support—no shared passwords, with optional limits and a simple activity log.', 'wppowersuite-lite' );
	}

	/**
	 * Get Module Context
	 *
	 * @since 1.0.0
	 * @return string|array Module context.
	 */
	public function getContext(): string|array {
		return [ 'admin', 'frontend', 'ajax' ];
	}

	/**
	 * Get Module Dependencies
	 *
	 * @since 1.0.0
	 * @return array<string> Module dependencies.
	 */
	public function getDependencies(): array {
		return [];
	}

	/**
	 * Initialize Module
	 *
	 * Sets up hooks for temporary login functionality.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	protected function registerHooks(): void {
		add_action( 'tl_cleanup_expired_logins', [ $this, 'cleanupExpiredLogins' ] );
		add_action( 'tl_cleanup_activity_logs', [ $this, 'cleanupOldActivityLogs' ] );

		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			return;
		}

		add_action( 'wp_logout', [ $this, 'onWpLogoutClearSessionMeta' ], 10, 1 );
		add_filter( 'wp_authenticate_user', [ $this, 'preventPasswordLogin' ], 10, 2 );
		add_filter( 'wpps_2fa_allow_bypass', [ $this, 'allow2FABypassForTemporaryLogin' ], 10, 2 );
		add_filter( 'allow_password_reset', [ $this, 'disablePasswordReset' ], 10, 2 );
		add_filter( 'wpps_tl_is_token_login_request', [ $this, 'filterIsTokenLoginRequest' ], 10, 1 );

		if ( self::requestHasTokenParam() ) {
			add_action( 'template_redirect', [ $this, 'handleTemporaryLogin' ], 1 );
		}

		add_action( 'init', [ $this, 'cleanupLegacyGlobalSessionMeta' ], 0 );
		add_action( 'set_current_user', [ $this, 'maybeRegisterSessionHooks' ], 1 );
		add_action( 'init', [ $this, 'maybeRegisterSessionHooks' ], 1 );
		$this->registerCapabilityOverlayHooks();

		if ( wp_doing_ajax() || is_admin() ) {
			$this->registerAdminAjaxHandlers();
		}

		if ( is_admin() ) {
			add_action( 'admin_menu', [ $this, 'addAdminMenu' ], 20 );
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAdminAssets' ] );
			add_filter( 'manage_users_columns', [ $this, 'addUsersListColumn' ] );
			add_filter( 'manage_users_custom_column', [ $this, 'renderUsersListColumn' ], 10, 3 );
		}
	}

	/**
	 * Revoke all links, invalidate sessions, and clear cron when the module is disabled.
	 */
	public static function cleanupOnDisable(): void {
		global $wpdb;

		if ( ! defined( 'WP_POWERSUITE_MODULES_DIR' ) ) {
			return;
		}

		$table = $wpdb->prefix . self::DB_TABLE_NAME;
		$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $found !== $table ) {
			return;
		}

		$now = current_time( 'mysql' );
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET is_revoked = 1, revoked_at = %s, session_version = session_version + 1 WHERE is_revoked = 0",
				$now
			)
		);

		$login_rows     = $wpdb->get_results( "SELECT id, user_id FROM {$table}" );
		$module         = new self();
		$current_uid    = is_user_logged_in() ? get_current_user_id() : 0;
		$logout_current = $current_uid > 0 && $module->getBoundLoginIdForCurrentSession( $current_uid ) > 0;
		if ( is_array( $login_rows ) ) {
			foreach ( $login_rows as $row ) {
				$module->destroyBoundSessionsForLogin( (int) $row->id, (int) $row->user_id );
			}
		}
		if ( ! empty( $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) {
			setcookie(
				self::SESSION_COOKIE_NAME,
				'',
				time() - YEAR_IN_SECONDS,
				COOKIEPATH,
				COOKIE_DOMAIN,
				is_ssl(),
				true
			);
			unset( $_COOKIE[ self::SESSION_COOKIE_NAME ] );
		}
		if ( $logout_current ) {
			wp_logout();
		}
	}

	/**
	 * Whether this request may skip IP / Coming Soon / site-password gates.
	 *
	 * Only a usable invite (live row, not revoked/expired/spent, IP/UA still allowed)
	 * qualifies. A non-empty `tl_token` query arg is not enough — that would let
	 * `?tl_token=x` open login, AJAX, and REST on a locked-down site.
	 *
	 * Other modules should use: apply_filters( 'wpps_tl_is_token_login_request', false )
	 *
	 * @param bool $is Prior value from other callbacks. Ignored: junk tokens must not inherit true.
	 */
	public function filterIsTokenLoginRequest( bool $is = false ): bool {
		unset( $is );
		return $this->getUsableLoginForRequest() !== null;
	}

	/**
	 * @return bool True when {@see self::TOKEN_PARAM} is present and non-empty.
	 */
	public static function requestHasTokenParam(): bool {
		return self::requestTokenValue() !== '';
	}

	/**
	 * Plain token from the current request, or empty.
	 */
	private static function requestTokenValue(): string {
		if ( ! isset( $_GET[ self::TOKEN_PARAM ] ) ) {
			return '';
		}
		return sanitize_text_field( wp_unslash( $_GET[ self::TOKEN_PARAM ] ) );
	}

	/**
	 * Usable login row for this request, or null.
	 *
	 * Memoized per token so IP / Coming Soon / Password / 2FA share one lookup.
	 * Does not claim a use slot.
	 */
	private function getUsableLoginForRequest(): ?object {
		$token = self::requestTokenValue();
		if ( $token === '' ) {
			return null;
		}
		if ( array_key_exists( $token, $this->usable_login_memo ) ) {
			return $this->usable_login_memo[ $token ];
		}
		$login = $this->getLoginByToken( $token );
		$this->usable_login_memo[ $token ] = $this->isLoginRowUsable( $login ) ? $login : null;
		return $this->usable_login_memo[ $token ];
	}

	/**
	 * Same pre-auth checks as {@see self::handleTemporaryLogin()} except rate-limit
	 * and use-slot claim (those must not run just because another module asked).
	 */
	private function isLoginRowUsable( ?object $login ): bool {
		if ( ! $login ) {
			return false;
		}
		if ( ! empty( $login->is_revoked ) ) {
			return false;
		}
		if ( $this->isExpired( $login ) ) {
			return false;
		}
		if ( ! empty( $login->single_use ) && (int) $login->used_count > 0 ) {
			return false;
		}
		if ( (int) ( $login->max_uses ?? 0 ) > 0 && (int) $login->used_count >= (int) $login->max_uses ) {
			return false;
		}
		if ( ! empty( $login->ip_restriction ) && ! $this->checkIpRestriction( (string) $login->ip_restriction, $this->getIpForRestrictionCheck() ) ) {
			return false;
		}
		$bind_ua = ! empty( $login->bind_user_agent ) || ! empty( $login->user_agent_hash );
		if ( $bind_ua && ! empty( $login->user_agent_hash ) ) {
			if ( ! hash_equals( (string) $login->user_agent_hash, hash( 'sha256', $this->getUserAgent() ) ) ) {
				return false;
			}
		}
		$user = get_userdata( (int) $login->user_id );
		return $user instanceof \WP_User;
	}

	/**
	 * Register hooks for an authenticated temporary session (skipped for normal users).
	 *
	 * @return void
	 */
	public function maybeRegisterSessionHooks(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$this->registerSessionEnforcementHooks();
	}

	/**
	 * Apply the invite role before third-party plugins register admin pages.
	 * LiteSpeed skips add_submenu_page callbacks when manage_options is still false.
	 */
	private function registerCapabilityOverlayHooks(): void {
		static $registered = false;
		if ( $registered ) {
			return;
		}
		$registered = true;

		add_filter( 'user_has_cap', [ $this, 'filterTemporarySessionCapabilities' ], 10, 4 );
		add_filter( 'get_user_metadata', [ $this, 'filterCapabilitiesMetaForTempSession' ], 10, 4 );
		add_action( 'set_current_user', [ $this, 'overlayCurrentUserInviteRole' ], 20 );
		add_action( 'admin_menu', [ $this, 'overlayCurrentUserInviteRole' ], 0 );
		add_action( 'admin_menu', [ $this, 'reregisterSkippedPluginMenus' ], 99 );
	}

	/**
	 * Register capability enforcement and admin restrictions for an active temporary session.
	 *
	 * Called on normal requests (init) and immediately when a token login establishes a session
	 * so role caps apply before redirect on the same request.
	 *
	 * @return void
	 */
	private function registerSessionEnforcementHooks(): void {
		static $registered = false;
		if ( $registered ) {
			return;
		}
		$registered = true;

		$this->ensureDatabaseTables();

		add_action( 'init', [ $this, 'checkExpiredLogins' ], 2 );
		add_action( 'admin_init', [ $this, 'blockPowersuiteAccess' ], 1 );
		add_action( 'admin_init', [ $this, 'blockPowersuitePluginTampering' ], 1 );
		add_action( 'admin_init', [ $this, 'blockTemporaryUserAdminActions' ], 2 );
		add_filter( 'rest_pre_dispatch', [ $this, 'blockTemporaryUserRestRequests' ], 10, 3 );
		$this->registerCapabilityOverlayHooks();
		$this->overlayCurrentUserInviteRole();
		foreach ( self::BLOCKED_MEMBERSHIP_OPTIONS as $option ) {
			add_filter( 'pre_update_option_' . $option, [ $this, 'filterBlockedMembershipOption' ], 10, 3 );
		}
		add_filter( 'pre_update_site_option_registration', [ $this, 'filterBlockedMembershipOption' ], 10, 3 );
		add_action( 'admin_bar_menu', [ $this, 'addAdminBarIndicator' ], 999 );
		add_action( 'wp_head', [ $this, 'addAdminBarStyles' ] );
		add_action( 'admin_head', [ $this, 'addAdminBarStyles' ] );
	}

	/**
	 * Single admin-ajax entry point (replaces ten separate wp_ajax_tl_* handlers).
	 */
	private function registerAdminAjaxHandlers(): void {
		add_action( 'wp_ajax_tl_admin', [ $this, 'ajaxAdminRouter' ] );
	}

	/**
	 * Route Temporary Login admin AJAX by tl_op (or legacy action=tl_* for exports/bookmarks).
	 */
	public function ajaxAdminRouter(): void {
		$op = isset( $_REQUEST['tl_op'] ) ? sanitize_key( wp_unslash( (string) $_REQUEST['tl_op'] ) ) : '';

		if ( $op === '' && isset( $_REQUEST['action'] ) ) {
			$legacy = sanitize_key( wp_unslash( (string) $_REQUEST['action'] ) );
			if ( str_starts_with( $legacy, 'tl_' ) && $legacy !== 'tl_admin' ) {
				$op = substr( $legacy, 3 );
			}
		}

		if ( $op !== '' ) {
			$this->ensureDatabaseTables();
		}

		match ( $op ) {
			'create_login'    => $this->ajaxCreateLogin(),
			'revoke_login'    => $this->ajaxRevokeLogin(),
			'enable_login'    => $this->ajaxEnableLogin(),
			'delete_login'    => $this->ajaxDeleteLogin(),
			'update_login'    => $this->ajaxUpdateLogin(),
			'get_login'       => $this->ajaxGetLogin(),
			'export_logs'     => $this->ajaxExportLogs(),
			'get_logs'        => $this->ajaxGetLogs(),
			'search_users'    => $this->ajaxSearchUsers(),
			'issue_copy_link'       => $this->ajaxIssueCopyLink(),
			'regenerate_login_link' => $this->ajaxRegenerateLoginLink(),
			default           => wp_send_json_error(
				[ 'message' => __( 'Unknown Temporary Login action.', 'wppowersuite-lite' ) ],
				400
			),
		};
	}

	/**
	 * Enqueue admin CSS/JS only on the Temporary Login manager screen.
	 *
	 * @param string $hook_suffix Current admin page hook.
	 * @return void
	 */
	public function enqueueAdminAssets( string $hook_suffix ): void {
		if ( ! \WP_PowerSuite\Core\AdminMenu::isPageHook( $hook_suffix, 'powersuite-temporary-login' ) ) {
			return;
		}

		if ( ! wp_style_is( 'wp-powersuite-admin', 'registered' ) && defined( 'WP_POWERSUITE_PLUGIN_URL' ) ) {
			wp_register_style(
				'wp-powersuite-admin',
				( function_exists( 'wp_powersuite_admin_asset_url' ) ? wp_powersuite_admin_asset_url( 'app.css' ) : WP_POWERSUITE_PLUGIN_URL . 'assets/admin/app.css' ),
				[ 'wp-admin' ],
				defined( 'WP_POWERSUITE_VERSION' ) ? WP_POWERSUITE_VERSION : self::MODULE_VERSION
			);
		}
		if ( wp_style_is( 'wp-powersuite-admin', 'registered' ) ) {
			wp_enqueue_style( 'wp-powersuite-admin' );
		}

		wp_enqueue_style(
			'wpps-temporary-login-admin',
			$this->getAssetUrl( 'temporary-login-admin.css' ),
			wp_style_is( 'wp-powersuite-admin', 'registered' ) ? [ 'wp-powersuite-admin' ] : [],
			self::MODULE_VERSION
		);
		wp_enqueue_script(
			'wpps-temporary-login-admin',
			$this->getAssetUrl( 'temporary-login-admin.js' ),
			[ 'jquery' ],
			self::MODULE_VERSION,
			true
		);

		wp_localize_script(
			'wpps-temporary-login-admin',
			'wppsTemporaryLogin',
			[
				'ajaxUrl'           => admin_url( 'admin-ajax.php' ),
				'homeUrl'           => home_url( '/' ),
				'tokenKey'          => self::TOKEN_PARAM,
				'nonce'             => wp_create_nonce( 'tl_nonce' ),
				'premiumUnlocked'   => FeatureGate::isPremiumLicensed(),
				'freeExpiryType'    => FeatureGate::FREE_EXPIRY_TYPE,
				'licenseUrl'        => admin_url( 'admin.php?page=powersuite-license' ),
				'strings'  => [
					'createTitle'       => __( 'Create Temporary Login', 'wppowersuite-lite' ),
					'editTitle'         => __( 'Edit Temporary Login', 'wppowersuite-lite' ),
					'createBtn'         => __( 'Create Login', 'wppowersuite-lite' ),
					'updateBtn'         => __( 'Update Login', 'wppowersuite-lite' ),
					'creating'          => __( 'Creating…', 'wppowersuite-lite' ),
					'updating'          => __( 'Updating…', 'wppowersuite-lite' ),
					'createdSuccess'    => __( 'Temporary login has been created successfully.', 'wppowersuite-lite' ),
					'genericError'      => __( 'An error occurred.', 'wppowersuite-lite' ),
					'requestFailed'     => __( 'The request failed. Please try again.', 'wppowersuite-lite' ),
					'searchFailed'      => __( 'User search failed. Please try again.', 'wppowersuite-lite' ),
					'handlerMissing'    => __( 'Temporary Login is not responding. Reload the page or clear WP PowerSuite cache, then try again.', 'wppowersuite-lite' ),
					'copyFallback'      => __( 'Copy the link below.', 'wppowersuite-lite' ),
					'copied'            => __( 'Copied', 'wppowersuite-lite' ),
					'copiedExclaim'     => __( 'Copied!', 'wppowersuite-lite' ),
					'confirmRevoke'     => __( 'Are you sure you want to revoke this login? Active sessions will be ended.', 'wppowersuite-lite' ),
					'confirmEnable'     => __( 'Are you sure you want to re-enable this login?', 'wppowersuite-lite' ),
					'confirmDelete'     => __( 'Are you sure you want to permanently delete this login? This action cannot be undone.', 'wppowersuite-lite' ),
					'revokeFailed'      => __( 'Failed to revoke login.', 'wppowersuite-lite' ),
					'enableFailed'      => __( 'Failed to enable login.', 'wppowersuite-lite' ),
					'deleteFailed'      => __( 'Failed to delete login.', 'wppowersuite-lite' ),
					'copyLinkFailed'    => __( 'Could not copy the login link.', 'wppowersuite-lite' ),
					'confirmRotateLink' => __( 'Generate a new link? The previous URL will stop working immediately, and any active session from that link will end.', 'wppowersuite-lite' ),
					'rotateLinkFailed'  => __( 'Could not rotate the login link.', 'wppowersuite-lite' ),
					'rotatedCopied'     => __( 'New link copied. The previous URL no longer works.', 'wppowersuite-lite' ),
					'loadingLogs'       => __( 'Loading logs...', 'wppowersuite-lite' ),
					'logsUnused'        => __( 'This login link has not been used yet.', 'wppowersuite-lite' ),
					'logsLoadFailed'    => __( 'Failed to load logs.', 'wppowersuite-lite' ),
					'logsError'         => __( 'An error occurred while loading logs.', 'wppowersuite-lite' ),
					'searchPlaceholder' => __( 'Search users by name, login, or email…', 'wppowersuite-lite' ),
					'searching'         => __( 'Searching…', 'wppowersuite-lite' ),
					'noUsers'           => __( 'No users found.', 'wppowersuite-lite' ),
					'colTime'           => __( 'Time', 'wppowersuite-lite' ),
					'colAction'         => __( 'Action', 'wppowersuite-lite' ),
					'colIp'             => __( 'IP Address', 'wppowersuite-lite' ),
					'colUa'             => __( 'User Agent', 'wppowersuite-lite' ),
					'colPage'           => __( 'Page', 'wppowersuite-lite' ),
				],
			]
		);
	}

	/**
	 * @param string $file Filename under assets/.
	 * @return string
	 */
	private function getAssetUrl( string $file ): string {
		return plugins_url( 'assets/' . $file, __FILE__ );
	}

	/**
	 * Add Temporary Login submenu under WP PowerSuite (dedicated page like Admin Menu Organizer).
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function addAdminMenu(): void {
		add_submenu_page(
			'powersuite',
			__( 'Temporary Login', 'wppowersuite-lite' ),
			__( 'Temporary Login', 'wppowersuite-lite' ),
			'manage_options',
			'powersuite-temporary-login',
			[ $this, 'renderTemporaryLoginPage' ]
		);
	}

	/**
	 * Render the Temporary Login admin page (full page, not modal).
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function renderTemporaryLoginPage(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wppowersuite-lite' ) );
		}

		$this->ensureDatabaseTables();

		if ( ! $this->tablesExist() ) {
			wp_die(
				esc_html__( 'Temporary Login could not create its database tables. Ask your host to grant CREATE TABLE permission for this site, then reload this page.', 'wppowersuite-lite' ),
				esc_html__( 'Database setup required', 'wppowersuite-lite' ),
				[ 'response' => 500 ]
			);
		}

		$module_id = self::MODULE_ID;
		$module_meta = [ '_directory' => __DIR__ ];
		include __DIR__ . '/admin.php';
	}

	/**
	 * Create database tables if missing (safe to call when the module is enabled).
	 *
	 * @return void
	 */
	public function setupDatabase(): void {
		$this->ensureDatabaseTables();
	}

	/**
	 * Ensure custom tables exist. The ready option is a cache only — tables are always verified.
	 *
	 * @return void
	 */
	private function ensureDatabaseTables(): void {
		if ( $this->tablesExist() ) {
			$this->upgradeSchema();
			if ( get_option( self::DB_READY_OPTION, '' ) !== '1' ) {
				update_option( self::DB_READY_OPTION, '1', false );
			}
			return;
		}

		delete_option( self::DB_READY_OPTION );
		$this->createTables();

		if ( $this->tablesExist() ) {
			update_option( self::DB_READY_OPTION, '1', false );
		}
	}

	/**
	 * Apply incremental schema changes via dbDelta (new installs and upgrades).
	 */
	private function upgradeSchema(): void {
		$this->createTables();
		$this->ensureSchemaColumns();
	}

	/**
	 * dbDelta can miss new columns on existing installs — verify with ALTER TABLE fallback.
	 */
	private function ensureSchemaColumns(): void {
		global $wpdb;

		$table = $wpdb->prefix . self::DB_TABLE_NAME;
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
			return;
		}

		$columns = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 );
		if ( ! is_array( $columns ) ) {
			return;
		}

		if ( ! in_array( 'bind_user_agent', $columns, true ) ) {
			$wpdb->query( "ALTER TABLE {$table} ADD COLUMN bind_user_agent tinyint(1) NOT NULL DEFAULT 0" );
		}

		if ( ! in_array( 'session_version', $columns, true ) ) {
			$wpdb->query( "ALTER TABLE {$table} ADD COLUMN session_version int(11) UNSIGNED NOT NULL DEFAULT 0" );
		}
	}

	/**
	 * @return bool True when both tl_temporary_logins and tl_activity_logs exist.
	 */
	private function tablesExist(): bool {
		global $wpdb;

		$main_table = $wpdb->prefix . self::DB_TABLE_NAME;
		$log_table  = $wpdb->prefix . self::DB_LOG_TABLE_NAME;

		$found_main = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $main_table ) );
		$found_log  = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $log_table ) );

		return $found_main === $main_table && $found_log === $log_table;
	}

	/**
	 * Create Database Tables
	 *
	 * Creates custom tables for storing temporary logins and activity logs.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	private function createTables(): void {
		global $wpdb;

		$charset_collate = $wpdb->get_charset_collate();
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$log_table_name = $wpdb->prefix . self::DB_LOG_TABLE_NAME;

		// Temporary logins table
		$sql = "CREATE TABLE IF NOT EXISTS $table_name (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			token varchar(255) NOT NULL,
			user_id bigint(20) UNSIGNED NOT NULL,
			created_by bigint(20) UNSIGNED NOT NULL,
			created_at datetime NOT NULL,
			expires_at datetime NOT NULL,
			single_use tinyint(1) DEFAULT 0,
			used_count int(11) DEFAULT 0,
			max_uses int(11) DEFAULT 0,
			role varchar(50) DEFAULT 'subscriber',
			allowed_capabilities text,
			ip_restriction varchar(255) DEFAULT NULL,
			user_agent_hash varchar(64) DEFAULT NULL,
			is_revoked tinyint(1) DEFAULT 0,
			revoked_at datetime DEFAULT NULL,
			revoked_by bigint(20) UNSIGNED DEFAULT NULL,
			redirect_to varchar(500) DEFAULT NULL,
			bind_user_agent tinyint(1) DEFAULT 0,
			session_version int(11) UNSIGNED DEFAULT 0,
			PRIMARY KEY (id),
			UNIQUE KEY token (token),
			KEY user_id (user_id),
			KEY expires_at (expires_at),
			KEY is_revoked (is_revoked)
		) $charset_collate;";

		// Activity logs table
		$log_sql = "CREATE TABLE IF NOT EXISTS $log_table_name (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			login_id bigint(20) UNSIGNED NOT NULL,
			user_id bigint(20) UNSIGNED NOT NULL,
			action varchar(50) NOT NULL,
			ip_address varchar(45) NOT NULL,
			user_agent text,
			page_accessed varchar(500) DEFAULT NULL,
			logged_at datetime NOT NULL,
			PRIMARY KEY (id),
			KEY login_id (login_id),
			KEY user_id (user_id),
			KEY logged_at (logged_at)
		) $charset_collate;";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
		dbDelta( $log_sql );

		// dbDelta can fail silently on some hosts; direct CREATE is a fallback.
		if ( ! $this->tablesExist() ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- CREATE TABLE from prefix + plugin constant; no user input.
			$wpdb->query( $sql );
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- CREATE TABLE from prefix + plugin constant; no user input.
			$wpdb->query( $log_sql );
		}
	}

	/**
	 * Handle Temporary Login
	 *
	 * Processes temporary login token and authenticates user. The user visits a URL
	 * with the token (e.g. ?tl_token=xxx); no login form or captcha is shown. The
	 * token acts as the secret, so login-form captchas do not apply. Use expiry,
	 * single-use, or IP restrictions to limit link abuse.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function handleTemporaryLogin(): void {
		if ( ! isset( $_GET[ self::TOKEN_PARAM ] ) ) {
			return;
		}

		$this->ensureDatabaseTables();

		if ( ! $this->allowTokenAttemptFromIp() ) {
			wp_die(
				esc_html__( 'Too many login attempts. Please try again in a few minutes.', 'wppowersuite-lite' ),
				esc_html__( 'Too Many Requests', 'wppowersuite-lite' ),
				[ 'response' => 429 ]
			);
		}

		$token = sanitize_text_field( wp_unslash( $_GET[ self::TOKEN_PARAM ] ) );

		if ( $token === '' ) {
			$this->bumpTokenFailure();
			wp_die( esc_html__( 'Invalid login token.', 'wppowersuite-lite' ), esc_html__( 'Authentication Error', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		$login = $this->getLoginByToken( $token );

		if ( ! $login ) {
			$this->bumpTokenFailure();
			wp_die( esc_html__( 'Invalid or expired login token.', 'wppowersuite-lite' ), esc_html__( 'Authentication Error', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		if ( $login->is_revoked ) {
			$this->bumpTokenFailure();
			$this->logActivity( $login->id, $login->user_id, 'login_denied_revoked', $this->getClientIp(), $this->getUserAgent() );
			wp_die( esc_html__( 'This login link has been revoked.', 'wppowersuite-lite' ), esc_html__( 'Access Denied', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		if ( $this->isExpired( $login ) ) {
			$this->bumpTokenFailure();
			$this->logActivity( $login->id, $login->user_id, 'login_denied_expired', $this->getClientIp(), $this->getUserAgent() );
			wp_die( esc_html__( 'This login link has expired.', 'wppowersuite-lite' ), esc_html__( 'Access Denied', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		if ( $login->single_use && $login->used_count > 0 ) {
			$this->bumpTokenFailure();
			$this->logActivity( $login->id, $login->user_id, 'login_denied_used', $this->getClientIp(), $this->getUserAgent() );
			wp_die( esc_html__( 'This login link has already been used.', 'wppowersuite-lite' ), esc_html__( 'Access Denied', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		if ( $login->max_uses > 0 && $login->used_count >= $login->max_uses ) {
			$this->bumpTokenFailure();
			$this->logActivity( $login->id, $login->user_id, 'login_denied_max_uses', $this->getClientIp(), $this->getUserAgent() );
			wp_die( esc_html__( 'Maximum login attempts reached for this link.', 'wppowersuite-lite' ), esc_html__( 'Access Denied', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		if ( ! empty( $login->ip_restriction ) && ! $this->checkIpRestriction( $login->ip_restriction, $this->getIpForRestrictionCheck() ) ) {
			$this->bumpTokenFailure();
			$this->logActivity( $login->id, $login->user_id, 'login_denied_ip', $this->getClientIp(), $this->getUserAgent() );
			wp_die( esc_html__( 'Access denied from this IP address.', 'wppowersuite-lite' ), esc_html__( 'Access Denied', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		// Check user-agent binding (on first use sets hash; thereafter enforces match).
		$bind_ua = ! empty( $login->bind_user_agent ) || ! empty( $login->user_agent_hash );
		if ( $bind_ua ) {
			$current_ua_hash = hash( 'sha256', $this->getUserAgent() );
			if ( empty( $login->user_agent_hash ) ) {
				$this->updateLoginUserAgent( $login->id, $current_ua_hash );
			} elseif ( $login->user_agent_hash !== $current_ua_hash ) {
				$this->bumpTokenFailure();
				$this->logActivity( $login->id, $login->user_id, 'login_denied_user_agent', $this->getClientIp(), $this->getUserAgent() );
				wp_die( esc_html__( 'Access denied: device mismatch.', 'wppowersuite-lite' ), esc_html__( 'Access Denied', 'wppowersuite-lite' ), [ 'response' => 403 ] );
			}
		}

		$user = get_userdata( $login->user_id );
		if ( ! $user ) {
			$this->bumpTokenFailure();
			wp_die( esc_html__( 'User not found.', 'wppowersuite-lite' ), esc_html__( 'Authentication Error', 'wppowersuite-lite' ), [ 'response' => 403 ] );
		}

		// Claim the use slot atomically before minting an auth cookie so two concurrent
		// hits on a single-use / max-uses link cannot both succeed.
		if ( ! $this->claimLoginUseSlot( $login ) ) {
			$this->bumpTokenFailure();
			$log_action = ! empty( $login->single_use ) ? 'login_denied_used' : 'login_denied_max_uses';
			$this->logActivity( $login->id, $login->user_id, $log_action, $this->getClientIp(), $this->getUserAgent() );
			wp_die(
				! empty( $login->single_use )
					? esc_html__( 'This login link has already been used.', 'wppowersuite-lite' )
					: esc_html__( 'Maximum login attempts reached for this link.', 'wppowersuite-lite' ),
				esc_html__( 'Access Denied', 'wppowersuite-lite' ),
				[ 'response' => 403 ]
			);
		}

		// Logout current user if different
		if ( is_user_logged_in() && get_current_user_id() !== $login->user_id ) {
			wp_logout();
		}

		$already_same_user = is_user_logged_in() && get_current_user_id() === $login->user_id;
		$was_temp_session  = $already_same_user && $this->isCurrentBrowserTempSession( (int) $login->user_id );

		if ( ! $already_same_user ) {
			$this->pending_bind_login = $login;
			add_filter( 'attach_session_information', [ $this, 'attachTempLoginSessionInfo' ], 10, 1 );
			add_action( 'set_logged_in_cookie', [ $this, 'captureLoggedInSessionToken' ], 10, 6 );
			wp_set_current_user( $login->user_id );
			wp_set_auth_cookie( $login->user_id, false );
			remove_action( 'set_logged_in_cookie', [ $this, 'captureLoggedInSessionToken' ], 10 );
			remove_filter( 'attach_session_information', [ $this, 'attachTempLoginSessionInfo' ], 10 );

			/** This action is documented in wp-includes/user.php */
			do_action( 'wp_login', $user->user_login, $user );
		}

		if ( ! $already_same_user || $was_temp_session ) {
			$this->establishTemporarySession( $login );
			if ( ! $this->bindPendingOrCurrentSession( $login ) ) {
				$this->destroyBoundSessionsForLogin( (int) $login->id, (int) $login->user_id );
				$this->clearTemporarySession( (int) $login->user_id );
				wp_logout();
				wp_die(
					esc_html__( 'Could not start a temporary session. Please try the login link again.', 'wppowersuite-lite' ),
					esc_html__( 'Access Denied', 'wppowersuite-lite' ),
					[ 'response' => 403 ]
				);
			}
		}

		$this->logActivity( $login->id, $login->user_id, 'login_success', $this->getClientIp(), $this->getUserAgent(), $this->getCurrentPage() );

		$this->clearTokenRateLimitForIp();

		$this->maybeNotifyAdminLoginUsed( $login, $user );

		do_action( 'tl_after_login_success', $login->id, $login->user_id );

		$this->redirectAfterLogin( $login );
	}

	/**
	 * Block owner-only WP PowerSuite admin pages for temporary sessions.
	 *
	 * Modules, Settings, and module screens stay available. License and
	 * Temporary Login management stay with the site owner.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function blockPowersuiteAccess(): void {
		if ( ! is_admin() || ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$had_temp_session = true;
		$login            = $this->getActiveSessionLogin( $user_id );

		if ( ! $login ) {
			$this->forceEndTemporarySession( $had_temp_session );
			return;
		}

		$current_page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
		$blocked      = self::BLOCKED_POWERSUITE_PAGES;
		if ( class_exists( '\WP_PowerSuite\Core\TemporarySessionGuard' ) ) {
			if ( \WP_PowerSuite\Core\TemporarySessionGuard::blocksAdminPage( $current_page ) ) {
				$this->logActivity( $login->id, $user_id, 'access_denied_powersuite', $this->getClientIp(), $this->getUserAgent(), $current_page );
				\WP_PowerSuite\Core\TemporarySessionGuard::denyAdminPage();
			}
		}
		if ( $current_page === '' || ! in_array( $current_page, $blocked, true ) ) {
			return;
		}

		$this->logActivity( $login->id, $user_id, 'access_denied_powersuite', $this->getClientIp(), $this->getUserAgent(), $current_page );
		wp_die(
			esc_html__( 'Temporary login users cannot manage licenses or temporary login links.', 'wppowersuite-lite' ),
			esc_html__( 'Access Denied', 'wppowersuite-lite' ),
			[ 'response' => 403 ]
		);
	}

	/**
	 * Block temporary users from editing/deleting any admin or super admin, and from adding any user.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function blockTemporaryUserAdminActions(): void {
		if ( ! is_admin() || ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( $user_id );
		if ( ! $login ) {
			return;
		}

		$script_name = basename( $_SERVER['SCRIPT_NAME'] ?? '' );

		if ( in_array( $script_name, self::ALWAYS_BLOCKED_ADMIN_SCREENS, true ) ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_screen', $script_name );
		}

		$this->blockTemporaryUserAccountActions( $login, $user_id, $script_name );
		$this->blockTemporaryPluginThemeScreens( $login, $user_id, $script_name );
		$this->blockTemporaryPluginThemeRequests( $login, $user_id, $script_name );
	}

	/**
	 * Block REST mutations that mirror admin_init restrictions for temporary sessions.
	 *
	 * @param mixed            $result  Response to replace.
	 * @param \WP_REST_Server  $server  REST server instance.
	 * @param \WP_REST_Request $request Request object.
	 * @return mixed
	 */
	public function blockTemporaryUserRestRequests( $result, $server, $request ) {
		unset( $server );

		if ( ! is_user_logged_in() ) {
			return $result;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return $result;
		}

		$login = $this->getActiveSessionLogin( $user_id );
		if ( ! $login ) {
			return $result;
		}

		if ( ! $request instanceof \WP_REST_Request ) {
			return $result;
		}

		$method = strtoupper( $request->get_method() );
		if ( ! in_array( $method, [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
			return $result;
		}

		$route = (string) $request->get_route();

		if ( preg_match( '#^/wp/v2/settings/?$#', $route ) && $this->restRequestChangesMembershipSettings( $request ) ) {
			return $this->restAccessDenied( $login, $user_id, 'access_denied_membership_settings' );
		}

		if ( preg_match( '#^/wp/v2/users(?:/|\?|$)#', $route ) ) {
			return $this->restAccessDenied( $login, $user_id, 'access_denied_create_user' );
		}

		$params = $request->get_json_params();
		if ( is_array( $params ) ) {
			$roles = $params['roles'] ?? null;
			if ( is_array( $roles ) && in_array( 'administrator', $roles, true ) ) {
				return $this->restAccessDenied( $login, $user_id, 'access_denied_create_admin' );
			}
			if ( isset( $params['role'] ) && sanitize_text_field( (string) $params['role'] ) === 'administrator' ) {
				return $this->restAccessDenied( $login, $user_id, 'access_denied_create_admin' );
			}
		}

		if ( preg_match( '#^/wp/v2/plugins#', $route ) ) {
			$denied = $this->restPluginThemeMutationDenied(
				$login,
				$request,
				self::PERM_INSTALL_PLUGINS,
				self::PERM_DELETE_PLUGINS
			);
			if ( $denied !== '' ) {
				return $this->restAccessDenied( $login, $user_id, $denied );
			}
		}

		if ( preg_match( '#^/wp/v2/themes#', $route ) ) {
			$denied = $this->restPluginThemeMutationDenied(
				$login,
				$request,
				self::PERM_INSTALL_THEMES,
				self::PERM_DELETE_THEMES
			);
			if ( $denied !== '' ) {
				return $this->restAccessDenied( $login, $user_id, $denied );
			}
		}

		return $result;
	}

	/**
	 * @return string Denied activity code, or empty when allowed.
	 */
	private function restPluginThemeMutationDenied(
		object $login,
		\WP_REST_Request $request,
		string $install_perm,
		string $delete_perm
	): string {
		$method      = strtoupper( $request->get_method() );
		$can_install = $this->loginAllows( $login, $install_perm );
		$can_delete  = $this->loginAllows( $login, $delete_perm );
		$install_code = $install_perm === self::PERM_INSTALL_THEMES
			? 'access_denied_theme_install'
			: 'access_denied_plugin_install';
		$delete_code = $delete_perm === self::PERM_DELETE_THEMES
			? 'access_denied_theme_delete'
			: 'access_denied_plugin_delete';

		if ( $method === 'POST' && ! $can_install ) {
			return $install_code;
		}
		if ( $method === 'DELETE' && ! $can_delete ) {
			return $delete_code;
		}
		if ( ! in_array( $method, [ 'PUT', 'PATCH' ], true ) ) {
			return '';
		}

		$status = $this->restRequestedStatus( $request );
		if ( in_array( $status, [ 'active', 'network-active' ], true ) && ! $can_install ) {
			return $install_code;
		}
		if ( $status === 'inactive' && ! $can_delete ) {
			return $delete_code;
		}
		if ( $status === '' && ! $can_install && ! $can_delete ) {
			return $install_code;
		}

		return '';
	}

	private function restRequestedStatus( \WP_REST_Request $request ): string {
		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = [];
		}

		$status = $params['status'] ?? $request->get_param( 'status' );

		return is_string( $status ) ? sanitize_key( $status ) : '';
	}

	/**
	 * Keep membership / takeover options unchanged during a temporary session.
	 * Unchanged form posts (Settings → General always sends these fields) still save.
	 *
	 * @param mixed $value     Incoming value.
	 * @param mixed $old_value Stored value.
	 * @param mixed $option    Option name.
	 * @return mixed
	 */
	public function filterBlockedMembershipOption( $value, $old_value, $option = '' ) {
		$option  = is_string( $option ) ? $option : '';
		$user_id = get_current_user_id();
		if ( $user_id <= 0 || ! $this->hasTempSessionMeta( $user_id ) ) {
			return $value;
		}

		if ( $this->membershipOptionUnchanged( $option, $value, $old_value ) ) {
			return $value;
		}

		$login = $this->getActiveSessionLogin( $user_id );
		if ( $login ) {
			$this->logActivity( $login->id, $user_id, 'access_denied_membership_settings', $this->getClientIp(), $this->getUserAgent(), $option );
		}

		return $old_value;
	}

	private function restRequestChangesMembershipSettings( \WP_REST_Request $request ): bool {
		$payload = [];
		$json    = $request->get_json_params();
		if ( is_array( $json ) ) {
			$payload = $json;
		}
		$body = $request->get_body_params();
		if ( is_array( $body ) ) {
			$payload = array_merge( $payload, $body );
		}

		foreach ( self::BLOCKED_MEMBERSHIP_OPTIONS as $key ) {
			if ( ! array_key_exists( $key, $payload ) ) {
				continue;
			}
			if ( ! $this->membershipOptionUnchanged( $key, $payload[ $key ], get_option( $key ) ) ) {
				return true;
			}
		}

		return false;
	}

	private function membershipOptionUnchanged( string $option, $new_value, $old_value ): bool {
		if ( $option === 'users_can_register' ) {
			return (int) $new_value === (int) $old_value;
		}

		$new = is_scalar( $new_value ) ? strtolower( trim( (string) $new_value ) ) : '';
		$old = is_scalar( $old_value ) ? strtolower( trim( (string) $old_value ) ) : '';

		return $new === $old;
	}

	/**
	 * @return \WP_Error
	 */
	private function restAccessDenied( object $login, int $user_id, string $log_action ): \WP_Error {
		$this->logActivity( $login->id, $user_id, $log_action, $this->getClientIp(), $this->getUserAgent() );

		$messages = $this->getTemporaryAccessDeniedMessages();

		return new \WP_Error(
			'tl_forbidden',
			$messages[ $log_action ] ?? $messages['access_denied_screen'],
			[ 'status' => 403 ]
		);
	}

	/**
	 * @return array<string, string>
	 */
	private function getTemporaryAccessDeniedMessages(): array {
		return [
			'access_denied_screen'            => __( 'Temporary login users cannot access this admin area.', 'wppowersuite-lite' ),
			'access_denied_edit_own'          => __( 'Temporary login users cannot edit their own account.', 'wppowersuite-lite' ),
			'access_denied_add_user'          => __( 'Temporary login users cannot add new users.', 'wppowersuite-lite' ),
			'access_denied_create_user'       => __( 'Temporary login users cannot add new users.', 'wppowersuite-lite' ),
			'access_denied_create_admin'      => __( 'Temporary login users cannot create or promote administrator accounts.', 'wppowersuite-lite' ),
			'access_denied_edit_admin'        => __( 'Temporary login users cannot edit administrator or super admin accounts.', 'wppowersuite-lite' ),
			'access_denied_delete_admin'      => __( 'Temporary login users cannot delete administrator or super admin accounts.', 'wppowersuite-lite' ),
			'access_denied_powersuite_plugin' => __( 'Temporary login users cannot deactivate or remove WP PowerSuite.', 'wppowersuite-lite' ),
			'access_denied_plugin_install'    => __( 'This temporary login does not allow installing or activating plugins.', 'wppowersuite-lite' ),
			'access_denied_plugin_delete'     => __( 'This temporary login does not allow deactivating or deleting plugins.', 'wppowersuite-lite' ),
			'access_denied_theme_install'     => __( 'This temporary login does not allow installing or activating themes.', 'wppowersuite-lite' ),
			'access_denied_theme_delete'      => __( 'This temporary login does not allow deleting themes.', 'wppowersuite-lite' ),
			'access_denied_membership_settings' => __( 'Temporary login users cannot change registration, the default role, or the site admin email.', 'wppowersuite-lite' ),
		];
	}

	/**
	 * @param string[] $columns Users list columns.
	 * @return string[]
	 */
	public function addUsersListColumn( array $columns ): array {
		$columns['wpps_tl_account'] = __( 'Temp access', 'wppowersuite-lite' );
		return $columns;
	}

	/**
	 * @param string $output      Custom column output.
	 * @param string $column_name Column key.
	 * @param int    $user_id     User ID.
	 * @return string
	 */
	public function renderUsersListColumn( string $output, string $column_name, int $user_id ): string {
		if ( $column_name !== 'wpps_tl_account' ) {
			return $output;
		}

		if ( get_user_meta( $user_id, self::USER_META_PASSWORDLESS_ACCOUNT, true ) === '1' ) {
			return '<span class="wpps-tl-user-badge" style="display:inline-block;padding:2px 6px;border-radius:3px;background:#fcf0f1;color:#8a2424;font-size:11px;font-weight:600;">'
				. esc_html__( 'Link-only', 'wppowersuite-lite' )
				. '</span>';
		}

		return '—';
	}

	/**
	 * Never allow temporary sessions to deactivate or delete WP PowerSuite.
	 *
	 * @return void
	 */
	public function blockPowersuitePluginTampering(): void {
		if ( ! is_admin() || ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( $user_id );
		if ( ! $login ) {
			return;
		}

		if ( ! defined( 'WP_POWERSUITE_PLUGIN_BASENAME' ) ) {
			return;
		}

		$basename = WP_POWERSUITE_PLUGIN_BASENAME;
		$touches_powersuite = false;

		if ( isset( $_REQUEST['plugin'] ) ) {
			$plugin = sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ) );
			if ( $plugin === $basename ) {
				$touches_powersuite = true;
			}
		}

		if ( ! $touches_powersuite && isset( $_REQUEST['checked'] ) && is_array( $_REQUEST['checked'] ) ) {
			$checked = array_map( 'sanitize_text_field', wp_unslash( $_REQUEST['checked'] ) );
			if ( in_array( $basename, $checked, true ) ) {
				$touches_powersuite = true;
			}
		}

		if ( ! $touches_powersuite ) {
			return;
		}

		$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
		$action2 = isset( $_REQUEST['action2'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action2'] ) ) : '';
		$blocked_actions = [
			'deactivate',
			'deactivate-selected',
			'delete-selected',
			'delete-plugin',
		];

		if ( in_array( $action, $blocked_actions, true ) || in_array( $action2, $blocked_actions, true ) ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_powersuite_plugin' );
		}
	}

	/**
	 * Profile, user create/edit/delete, and administrator promotion rules.
	 *
	 * @param object $login       Active login row.
	 * @param int    $user_id     Current user ID.
	 * @param string $script_name Current admin script basename.
	 * @return void
	 */
	private function blockTemporaryUserAccountActions( object $login, int $user_id, string $script_name ): void {
		if ( $script_name === 'profile.php' ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_edit_own' );
		}

		if ( $script_name === 'user-new.php' ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_add_user' );
		}

		if ( isset( $_POST['action'] ) && sanitize_text_field( wp_unslash( $_POST['action'] ) ) === 'createuser' ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_create_user' );
		}

		$this->blockTemporaryAdministratorPromotion( $login, $user_id );

		$edit_user_id = 0;
		if ( isset( $_GET['user_id'] ) ) {
			$edit_user_id = absint( $_GET['user_id'] );
		} elseif ( isset( $_POST['user_id'] ) ) {
			$edit_user_id = absint( $_POST['user_id'] );
		}

		if ( $script_name === 'user-edit.php' && $edit_user_id > 0 ) {
			if ( $edit_user_id === $user_id ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_edit_own' );
			}
			$edit_user = get_userdata( $edit_user_id );
			if ( $edit_user && $this->userIsPrivilegedAdmin( $edit_user ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_edit_admin' );
			}
		}

		$this->blockTemporaryAdministratorDeletion( $login, $user_id );
	}

	/**
	 * Plugin/theme list and install screens (allowed only when enabled on the login row).
	 *
	 * @param object $login       Active login row.
	 * @param int    $user_id     Current user ID.
	 * @param string $script_name Current admin script basename.
	 * @return void
	 */
	private function blockTemporaryPluginThemeScreens( object $login, int $user_id, string $script_name ): void {
		if ( $script_name === 'plugin-install.php' && ! $this->loginAllows( $login, self::PERM_INSTALL_PLUGINS ) ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_screen', $script_name );
		}

		if ( $script_name === 'theme-install.php' && ! $this->loginAllows( $login, self::PERM_INSTALL_THEMES ) ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_screen', $script_name );
		}
	}

	/**
	 * Block plugin/theme POST actions when the login row did not grant that permission.
	 *
	 * @param object $login       Active login row.
	 * @param int    $user_id     Current user ID.
	 * @param string $script_name Current admin script basename.
	 * @return void
	 */
	private function blockTemporaryPluginThemeRequests( object $login, int $user_id, string $script_name ): void {
		$action = $this->getRequestedAdminAction();
		if ( $action === '' ) {
			return;
		}

		$plugin_surfaces = [ 'plugins.php', 'plugin-install.php', 'update.php', 'admin-ajax.php' ];
		$theme_surfaces  = [ 'themes.php', 'theme-install.php', 'update.php', 'admin-ajax.php' ];

		$install_plugin_actions = [
			'install-plugin',
			'upload-plugin',
			'upload-plugin-cancel-overwrite',
			'activate',
			'activate-selected',
			'activate-plugin',
			'update-plugin',
			'upgrade-plugin',
			'update-selected',
			'search-install-plugins',
			'resume',
			'enable-auto-update',
			'disable-auto-update',
			'enable-auto-update-selected',
			'disable-auto-update-selected',
		];
		$delete_plugin_actions = [
			'delete-selected',
			'delete-plugin',
			'deactivate',
			'deactivate-selected',
		];
		$install_theme_actions = [
			'install-theme',
			'upload-theme',
			'upload-theme-cancel-overwrite',
			'activate',
			'activate-selected',
			'update-theme',
			'upgrade-theme',
			'update-selected-themes',
			'search-install-themes',
		];
		$delete_theme_actions = [
			'delete',
			'delete-selected',
			'delete-theme',
		];

		if ( $action === 'toggle-auto-updates' ) {
			$type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( (string) $_POST['type'] ) ) : '';
			if ( $type === 'plugin' && ! $this->loginAllows( $login, self::PERM_INSTALL_PLUGINS ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_plugin_install' );
			}
			if ( $type === 'theme' && ! $this->loginAllows( $login, self::PERM_INSTALL_THEMES ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_theme_install' );
			}
			return;
		}

		if ( in_array( $script_name, $plugin_surfaces, true ) ) {
			if ( in_array( $action, $install_plugin_actions, true ) && ! $this->loginAllows( $login, self::PERM_INSTALL_PLUGINS ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_plugin_install' );
			}
			if ( in_array( $action, $delete_plugin_actions, true ) && ! $this->loginAllows( $login, self::PERM_DELETE_PLUGINS ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_plugin_delete' );
			}
		}

		if ( in_array( $script_name, $theme_surfaces, true ) ) {
			if ( in_array( $action, $install_theme_actions, true ) && ! $this->loginAllows( $login, self::PERM_INSTALL_THEMES ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_theme_install' );
			}
			if ( in_array( $action, $delete_theme_actions, true ) && ! $this->loginAllows( $login, self::PERM_DELETE_THEMES ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_theme_delete' );
			}
		}
	}

	private function getRequestedAdminAction(): string {
		$action  = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['action'] ) ) : '';
		$action2 = isset( $_REQUEST['action2'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['action2'] ) ) : '';

		if ( $action !== '' && $action !== '-1' ) {
			return $action;
		}
		if ( $action2 !== '' && $action2 !== '-1' ) {
			return $action2;
		}

		return '';
	}

	/**
	 * @param object $login Active login row.
	 * @param int    $user_id Current user ID.
	 * @return void
	 */
	private function blockTemporaryAdministratorPromotion( object $login, int $user_id ): void {
		$new_role = '';
		if ( isset( $_POST['role'] ) ) {
			$new_role = sanitize_text_field( wp_unslash( $_POST['role'] ) );
		} elseif ( isset( $_POST['new_role'] ) ) {
			$new_role = sanitize_text_field( wp_unslash( $_POST['new_role'] ) );
		}

		if ( $new_role === 'administrator' ) {
			$this->denyTemporaryAdminAccess( $login, $user_id, 'access_denied_create_admin' );
		}
	}

	/**
	 * @param object $login   Active login row.
	 * @param int    $user_id Current user ID.
	 * @return void
	 */
	private function blockTemporaryAdministratorDeletion( object $login, int $user_id ): void {
		$action  = $this->getRequestedAdminAction();
		$blocked = [ 'delete', 'delete-selected', 'dodelete', 'doremove', 'remove', 'resetpassword' ];
		if ( ! in_array( $action, $blocked, true ) ) {
			return;
		}

		$target_ids = [];
		if ( isset( $_REQUEST['user'] ) ) {
			$target_ids[] = absint( wp_unslash( $_REQUEST['user'] ) );
		}
		if ( isset( $_REQUEST['users'] ) && is_array( $_REQUEST['users'] ) ) {
			$target_ids = array_merge( $target_ids, array_map( 'absint', wp_unslash( $_REQUEST['users'] ) ) );
		}

		$code = $action === 'resetpassword' ? 'access_denied_edit_admin' : 'access_denied_delete_admin';
		foreach ( array_filter( $target_ids ) as $target_id ) {
			if ( \WP_PowerSuite\Core\TemporarySessionGuard::accountHasDurablePrivileges( (int) $target_id ) ) {
				$this->denyTemporaryAdminAccess( $login, $user_id, $code );
			}
		}
	}

	/**
	 * @param \WP_User $user User object.
	 * @return bool
	 */
	private function userIsPrivilegedAdmin( \WP_User $user ): bool {
		return \WP_PowerSuite\Core\TemporarySessionGuard::accountHasDurablePrivileges( (int) $user->ID );
	}

	/**
	 * @return array<string, bool>
	 */
	private function getDefaultLoginPermissions(): array {
		return [
			self::PERM_INSTALL_PLUGINS => false,
			self::PERM_DELETE_PLUGINS  => false,
			self::PERM_INSTALL_THEMES  => false,
			self::PERM_DELETE_THEMES   => false,
		];
	}

	/**
	 * @param array<string, mixed> $args Create/update request args.
	 * @return string JSON for allowed_capabilities column.
	 */
	private function encodeLoginPermissionsFromArgs( array $args ): string {
		$perms = $this->getDefaultLoginPermissions();
		foreach ( array_keys( $perms ) as $key ) {
			if ( ! empty( $args[ $key ] ) ) {
				$perms[ $key ] = true;
			}
		}
		return wp_json_encode( $perms ) ?: '{}';
	}

	/**
	 * @param object $login Login row.
	 * @return array<string, bool>
	 */
	private function getLoginPermissions( object $login ): array {
		$perms = $this->getDefaultLoginPermissions();
		if ( empty( $login->allowed_capabilities ) ) {
			return $perms;
		}
		$decoded = json_decode( (string) $login->allowed_capabilities, true );
		if ( ! is_array( $decoded ) ) {
			return $perms;
		}
		foreach ( array_keys( $perms ) as $key ) {
			if ( array_key_exists( $key, $decoded ) ) {
				$perms[ $key ] = (bool) $decoded[ $key ];
			}
		}
		return $perms;
	}

	/**
	 * @param object $login Active login row.
	 * @param string $perm  One of the PERM_* constants.
	 * @return bool
	 */
	private function loginAllows( object $login, string $perm ): bool {
		$perms = $this->getLoginPermissions( $login );
		return ! empty( $perms[ $perm ] );
	}

	/**
	 * Whether the current temporary session granted an invite permission.
	 * Non-temp sessions return true so callers can AND this with role caps.
	 */
	public function currentSessionAllows( string $permission ): bool {
		$user_id = get_current_user_id();
		if ( ! $this->isCurrentBrowserTempSession( $user_id ) ) {
			return true;
		}

		$login = $this->getActiveSessionLogin( $user_id );
		if ( ! $login ) {
			return false;
		}

		return $this->loginAllows( $login, $permission );
	}

	/**
	 * @param array<string, mixed> $args Update/create args.
	 * @return bool
	 */
	private function argsIncludePermissionFields( array $args ): bool {
		foreach ( array_keys( $this->getDefaultLoginPermissions() ) as $key ) {
			if ( array_key_exists( $key, $args ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param object      $login       Active login row.
	 * @param int         $user_id     Current user ID.
	 * @param string      $log_action  Activity log action key.
	 * @param string|null $page        Optional page identifier for the log.
	 * @return void
	 */
	private function denyTemporaryAdminAccess( object $login, int $user_id, string $log_action, ?string $page = null ): void {
		$this->logActivity( $login->id, $user_id, $log_action, $this->getClientIp(), $this->getUserAgent(), $page ?? '' );

		$messages = $this->getTemporaryAccessDeniedMessages();

		wp_die(
			esc_html( $messages[ $log_action ] ?? $messages['access_denied_screen'] ),
			esc_html__( 'Access Denied', 'wppowersuite-lite' ),
			[ 'response' => 403 ]
		);
	}

	/**
	 * Track Page Access
	 *
	 * Logs page access for temporary users (optional tracking).
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function trackPageAccess(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( $user_id );

		if ( ! $login ) {
			return;
		}

		// Only log page access occasionally to avoid flooding the database
		// Log every 10th page access or important pages
		$should_log = false;
		$current_page = $this->getCurrentPage();
		
		// Always log admin pages
		if ( is_admin() ) {
			$should_log = true;
		} else {
			// Log frontend pages occasionally (every 10th access)
			$last_logged = get_transient( 'tl_last_page_log_' . $login->id );
			if ( ! $last_logged || ( time() - $last_logged ) > 300 ) { // Log at most every 5 minutes
				$should_log = true;
				set_transient( 'tl_last_page_log_' . $login->id, time(), 300 );
			}
		}

		if ( $should_log ) {
			$this->logActivity( $login->id, $user_id, 'page_access', $this->getClientIp(), $this->getUserAgent(), $current_page );
		}
	}

	/**
	 * Check Expired Logins
	 *
	 * Checks if current user's temporary login has expired and logs them out.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function checkExpiredLogins(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = absint( get_current_user_id() );
		if ( ! $this->isCurrentBrowserTempSession( $user_id ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( $user_id );
		if ( ! $login ) {
			$this->forceEndTemporarySession( true );
			return;
		}

		if ( $login->is_revoked ) {
			$this->logActivity( $login->id, $user_id, 'auto_logout_revoked', $this->getClientIp(), $this->getUserAgent() );
			$this->endBoundOrCookieSession( $user_id, (int) $login->id );
			return;
		}

		if ( $this->isExpired( $login ) ) {
			$this->logActivity( $login->id, $user_id, 'auto_logout_expired', $this->getClientIp(), $this->getUserAgent() );
			$this->endBoundOrCookieSession( $user_id, (int) $login->id );
			return;
		}
	}

	/**
	 * Prevent Password Login
	 *
	 * Prevents passwordless-only accounts (created via email) from using wp-login.
	 *
	 * @since 1.0.0
	 * @param \WP_User|\WP_Error $user User object or error.
	 * @param string             $password Password.
	 * @return \WP_User|\WP_Error User object or error.
	 */
	public function preventPasswordLogin( $user, string $password ) {
		if ( $user instanceof \WP_User && $this->mustUseTemporaryLink( (int) $user->ID ) ) {
			return new \WP_Error(
				'temporary_user',
				__( 'This account can only be accessed via temporary login link.', 'wppowersuite-lite' )
			);
		}
		return $user;
	}

	/**
	 * Allow 2FA bypass when logging in via a valid temporary login link.
	 *
	 * @since 1.0.0
	 * @param bool     $bypass Whether to bypass 2FA.
	 * @param \WP_User $user   User being authenticated.
	 * @return bool Whether to bypass 2FA.
	 */
	public function allow2FABypassForTemporaryLogin( bool $bypass, \WP_User $user ): bool {
		$login = $this->getUsableLoginForRequest();
		if ( ! $login || (int) $login->user_id !== (int) $user->ID ) {
			return $bypass;
		}
		return true;
	}

	/**
	 * Disable Password Reset
	 *
	 * Prevents temporary users from resetting their password.
	 *
	 * @since 1.0.0
	 * @param bool $allow Whether password reset is allowed.
	 * @param int  $user_id User ID.
	 * @return bool Whether password reset is allowed.
	 */
	public function disablePasswordReset( bool $allow, int $user_id ): bool {
		if ( $this->mustUseTemporaryLink( $user_id ) ) {
			return false;
		}
		return $allow;
	}

	/**
	 * Limit capabilities to the role assigned on the temporary login row.
	 *
	 * @param array<string, bool> $allcaps All capabilities for the user.
	 * @param array<int, string>  $caps    Required capabilities.
	 * @param array<int, mixed>   $args    Capability check arguments.
	 * @param \WP_User            $user    User object.
	 * @return array<string, bool>
	 */
	public function filterTemporarySessionCapabilities( array $allcaps, array $caps, array $args, \WP_User $user ): array {
		if ( $this->fail_closed_session ) {
			return [];
		}

		if ( ! $this->hasTempSessionMeta( (int) $user->ID ) ) {
			return $allcaps;
		}

		$login = $this->getActiveSessionLogin( (int) $user->ID );
		if ( ! $login ) {
			$this->fail_closed_session = true;
			return [];
		}

		$role_caps = $this->getSessionRoleCapabilities( $login );
		if ( $role_caps === null ) {
			$this->fail_closed_session = true;
			return [];
		}

		// Invite role (+ invite permission checkboxes) is authoritative.
		// Never merge the account's stored allcaps: an existing administrator
		// invited as editor/subscriber would otherwise regain manage_options
		// and other privileged caps from $allcaps.
		unset( $allcaps );

		$effective = [];
		foreach ( $role_caps as $cap => $granted ) {
			$effective[ $cap ] = (bool) $granted;
		}

		$role_slug = sanitize_key( (string) ( $login->role ?? '' ) );
		if ( $role_slug !== '' ) {
			$effective[ $role_slug ] = true;
		}

		return $effective;
	}

	/**
	 * Make this request look like the invite role without writing wp_capabilities.
	 * Third-party plugins that check $user->roles still see administrator.
	 */
	public function overlayCurrentUserInviteRole(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user = wp_get_current_user();
		if ( (int) $user->ID <= 0 || ! $this->hasTempSessionMeta( (int) $user->ID ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( (int) $user->ID );
		if ( ! $login ) {
			return;
		}

		$role = sanitize_key( (string) ( $login->role ?? '' ) );
		if ( $role === '' || ! get_role( $role ) ) {
			return;
		}

		$role_caps = $this->getSessionRoleCapabilities( $login );
		if ( $role_caps === null ) {
			return;
		}

		$user->caps    = [ $role => true ];
		$user->roles   = [ $role ];
		$user->allcaps = $role_caps;
		$user->allcaps[ $role ] = true;
	}

	/**
	 * Re-run plugin menu registration if the first pass saw a subscriber.
	 * WordPress then omits the page hook and shows "Cannot load {slug}".
	 */
	public function reregisterSkippedPluginMenus(): void {
		if ( ! is_admin() || ! is_user_logged_in() ) {
			return;
		}

		$this->overlayCurrentUserInviteRole();
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : '';
		if ( $page === '' || ! str_starts_with( $page, 'litespeed' ) ) {
			return;
		}

		if ( ! class_exists( '\LiteSpeed\Admin_Display', false ) || ! method_exists( '\LiteSpeed\Admin_Display', 'cls' ) ) {
			return;
		}

		$hook = function_exists( 'get_plugin_page_hook' ) ? get_plugin_page_hook( $page, 'litespeed' ) : null;
		if ( is_string( $hook ) && $hook !== '' && has_action( $hook ) ) {
			return;
		}

		$display = \LiteSpeed\Admin_Display::cls();
		if ( is_object( $display ) && method_exists( $display, 'register_admin_menu' ) ) {
			$display->register_admin_menu();
		}
	}

	/**
	 * So get_userdata() / new WP_User() also see the invite role for this browser.
	 * Does not write wp_capabilities. Other users are unchanged.
	 *
	 * Must not call get_current_user_id() / wp_get_current_user() — WP_User cap
	 * load re-enters get_user_metadata (infinite recursion during auth cookies).
	 *
	 * @param mixed  $value     Filter seed (null unless another filter set it).
	 * @param int    $object_id User ID.
	 * @param string $meta_key  Meta key.
	 * @param bool   $single    Whether a single value was requested.
	 * @return mixed
	 */
	public function filterCapabilitiesMetaForTempSession( $value, $object_id, $meta_key, $single ) {
		if ( $value !== null ) {
			return $value;
		}

		static $guard = false;
		if ( $guard ) {
			return $value;
		}

		global $wpdb;
		$cap_key = $wpdb->get_blog_prefix() . 'capabilities';
		if ( $meta_key !== $cap_key ) {
			return $value;
		}

		$user_id    = (int) $object_id;
		$current_id = $this->getAlreadyResolvedCurrentUserId();
		if ( $user_id <= 0 || $current_id <= 0 || $user_id !== $current_id ) {
			return $value;
		}

		$guard = true;
		try {
			if ( ! $this->hasTempSessionMeta( $user_id ) ) {
				return $value;
			}

			$login = $this->getActiveSessionLogin( $user_id );
			if ( ! $login ) {
				return $value;
			}

			$role = sanitize_key( (string) ( $login->role ?? '' ) );
			if ( $role === '' || ! get_role( $role ) ) {
				return $value;
			}

			return [ [ $role => true ] ];
		} finally {
			$guard = false;
		}
	}

	/**
	 * Current user ID only if WordPress already resolved it.
	 *
	 * Peeking at $GLOBALS['current_user'] avoids bootstrapping auth, which
	 * loads capabilities and re-enters get_user_metadata.
	 */
	private function getAlreadyResolvedCurrentUserId(): int {
		if ( ! isset( $GLOBALS['current_user'] ) || ! ( $GLOBALS['current_user'] instanceof \WP_User ) ) {
			return 0;
		}

		return (int) $GLOBALS['current_user']->ID;
	}

	/**
	 * @param object $login Active session login row.
	 * @return array<string, bool>|null
	 */
	private function getSessionRoleCapabilities( object $login ): ?array {
		if ( $this->session_role_caps_cache !== null ) {
			return $this->session_role_caps_cache;
		}

		$role_slug = sanitize_text_field( $login->role ?? 'subscriber' );
		$wp_role   = get_role( $role_slug );
		if ( ! $wp_role ) {
			$wp_role = get_role( 'subscriber' );
		}
		if ( ! $wp_role ) {
			return null;
		}

		$caps  = $wp_role->capabilities;
		$perms = $this->getLoginPermissions( $login );

		// Invite checkboxes are the only source of install/activate/upload/update
		// and delete/deactivate — not the role map.
		$install_plugins = (bool) $perms[ self::PERM_INSTALL_PLUGINS ];
		$caps['install_plugins'] = $install_plugins;
		$caps['upload_plugins']  = $install_plugins;
		$caps['update_plugins']  = $install_plugins;
		$caps['resume_plugins']  = $install_plugins;
		if ( $install_plugins ) {
			$caps['activate_plugins'] = true;
		}

		$install_themes = (bool) $perms[ self::PERM_INSTALL_THEMES ];
		$caps['install_themes'] = $install_themes;
		$caps['upload_themes']  = $install_themes;
		$caps['update_themes']  = $install_themes;
		$caps['resume_themes']  = $install_themes;
		if ( $install_themes ) {
			$caps['switch_themes'] = true;
		}

		$caps['delete_plugins']     = (bool) $perms[ self::PERM_DELETE_PLUGINS ];
		$caps['deactivate_plugins'] = (bool) $perms[ self::PERM_DELETE_PLUGINS ];
		$caps['delete_themes']      = (bool) $perms[ self::PERM_DELETE_THEMES ];

		// Never grant file editors or core updates during a temporary session.
		foreach ( [ 'edit_plugins', 'edit_themes', 'update_core', 'update_languages', 'install_languages' ] as $forbidden_cap ) {
			$caps[ $forbidden_cap ] = false;
		}

		$this->session_role_caps_cache = $caps;

		return $this->session_role_caps_cache;
	}

	/**
	 * Whether this account must sign in via a temporary link (not password).
	 *
	 * Only users created through the module's email flow are passwordless. Existing
	 * accounts (e.g. administrators) keep normal password login even while a temp
	 * link is active, so the site owner is never locked out of wp-login.
	 *
	 * @param int $user_id User ID.
	 * @return bool
	 */
	private function mustUseTemporaryLink( int $user_id ): bool {
		return get_user_meta( $user_id, self::USER_META_PASSWORDLESS_ACCOUNT, true ) === '1';
	}

	/**
	 * Add Admin Bar Indicator
	 *
	 * Adds a warning indicator in the admin bar for temporary logins.
	 *
	 * @since 1.0.0
	 * @param \WP_Admin_Bar $wp_admin_bar Admin bar object.
	 * @return void
	 */
	public function addAdminBarIndicator( \WP_Admin_Bar $wp_admin_bar ): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( $user_id );

		if ( ! $login ) {
			return;
		}

		$expires_ts = self::expiresAtToTimestamp( (string) $login->expires_at );
		$time_left  = $expires_ts - current_time( 'timestamp' );
		$hours_left = round( $time_left / HOUR_IN_SECONDS, 1 );

		$wp_admin_bar->add_node(
			[
				'id'    => 'tl-temporary-access',
				'title' => sprintf(
					'⚠ %s',
					sprintf(
						/* translators: %s: hours remaining */
						__( 'Temporary Access (%.1f hours left)', 'wppowersuite-lite' ),
						$hours_left
					)
				),
				'href'  => admin_url(),
				'meta'  => [
					'class' => 'tl-temporary-access-warning',
				],
			]
		);
	}

	/**
	 * Add Admin Bar Styles
	 *
	 * Adds CSS for the admin bar indicator.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function addAdminBarStyles(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! $this->hasTempSessionMeta( $user_id ) ) {
			return;
		}

		$login = $this->getActiveSessionLogin( $user_id );

		if ( ! $login ) {
			return;
		}

		?>
		<?php ob_start(); ?>
			#wpadminbar #wp-admin-bar-tl-temporary-access {
				background-color: #d63638 !important;
			}
			#wpadminbar #wp-admin-bar-tl-temporary-access .ab-item {
				color: #fff !important;
				font-weight: 600;
			}
			#wpadminbar #wp-admin-bar-tl-temporary-access:hover .ab-item {
				background-color: #b32d2e !important;
			}
<?php \WP_PowerSuite\Core\InlineAssets::style( 'wpps-ia-modules-temporary-login-module-php-style-1', (string) ob_get_clean() ); ?>
		<?php
	}

	/**
	 * Generate a one-time plain token (only shown to admins at creation / re-issue).
	 *
	 * @return string 64-character hex string.
	 */
	private function generatePlainToken(): string {
		return bin2hex( random_bytes( 32 ) );
	}

	/**
	 * Hash a plain token for database storage.
	 *
	 * @param string $plain_token Plain token from the login URL.
	 * @return string
	 */
	private function hashToken( string $plain_token ): string {
		return hash_hmac( 'sha256', $plain_token, $this->getSecretKey() );
	}

	/**
	 * Get Secret Key
	 *
	 * Gets or generates a secret key for HMAC signing.
	 *
	 * @since 1.0.0
	 * @return string Secret key.
	 */
	private function getSecretKey(): string {
		$key = get_option( 'tl_secret_key' );
		if ( empty( $key ) ) {
			$key = wp_generate_password( 64, true, true );
			update_option( 'tl_secret_key', $key, false );
		}
		return $key;
	}

	/**
	 * Create Temporary Login
	 *
	 * Creates a new temporary login record.
	 *
	 * @since 1.0.0
	 * @param array $args Login arguments.
	 * @return array{id:int,token:string}|\WP_Error Login ID and plain token, or error.
	 */
	public function createLogin( array $args ): array|\WP_Error {
		global $wpdb;

		$this->ensureDatabaseTables();

		if ( ! $this->tablesExist() ) {
			return new \WP_Error(
				'db_missing',
				__( 'Temporary Login database tables are not available.', 'wppowersuite-lite' )
			);
		}

		// If user_id is provided, use it (for selecting existing user)
		if ( ! empty( $args['user_id'] ) ) {
			$user_id = absint( $args['user_id'] );
			$user = get_userdata( $user_id );
			if ( ! $user ) {
				return new \WP_Error( 'invalid_user', __( 'User not found.', 'wppowersuite-lite' ) );
			}
			if ( in_array( 'administrator', $user->roles, true ) ) {
				$creator = wp_get_current_user();
				if ( ! $creator || ! in_array( 'administrator', $creator->roles, true ) ) {
					return new \WP_Error(
						'admin_target',
						__( 'Temporary login links for administrator accounts can only be created by administrators.', 'wppowersuite-lite' )
					);
				}
			}
		} elseif ( ! empty( $args['email'] ) ) {
			if ( ! is_email( $args['email'] ) ) {
				return new \WP_Error( 'invalid_email', __( 'Invalid email address.', 'wppowersuite-lite' ) );
			}

			$user = get_user_by( 'email', $args['email'] );
			if ( ! $user ) {
				$username = $this->generateUsername( $args['email'] );
				$row_role = sanitize_text_field( $args['role'] ?? 'subscriber' );
				if ( ! get_role( $row_role ) ) {
					$row_role = 'subscriber';
				}
				if ( $row_role === 'administrator' ) {
					$creator = wp_get_current_user();
					if ( ! $creator || ! in_array( 'administrator', $creator->roles, true ) ) {
						return new \WP_Error(
							'role_denied',
							__( 'Only administrators may create administrator accounts.', 'wppowersuite-lite' )
						);
					}
				}
				if ( ! current_user_can( 'create_users' ) ) {
					return new \WP_Error(
						'create_users',
						__( 'You do not have permission to create users.', 'wppowersuite-lite' )
					);
				}
				$user_id = wp_insert_user( [
					'user_login' => $username,
					'user_pass'  => wp_generate_password( 32, true, true ),
					'user_email' => $args['email'],
					'role'       => 'subscriber',
				] );
				if ( is_wp_error( $user_id ) ) {
					return $user_id;
				}
				update_user_meta( $user_id, self::USER_META_PASSWORDLESS_ACCOUNT, '1' );
			} else {
				$user_id = $user->ID;
				if ( in_array( 'administrator', $user->roles, true ) ) {
					$creator = wp_get_current_user();
					if ( ! $creator || ! in_array( 'administrator', $creator->roles, true ) ) {
						return new \WP_Error(
							'admin_target',
							__( 'Temporary login links for administrator accounts can only be created by administrators.', 'wppowersuite-lite' )
						);
					}
				}
			}
		} else {
			return new \WP_Error( 'missing_user', __( 'User ID or email is required.', 'wppowersuite-lite' ) );
		}

		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return new \WP_Error( 'invalid_user', __( 'User not found.', 'wppowersuite-lite' ) );
		}

		if ( is_multisite() && is_super_admin( $user_id ) ) {
			return new \WP_Error(
				'super_admin_target',
				__( 'Temporary login links cannot be created for super admins.', 'wppowersuite-lite' )
			);
		}

		$row_role = sanitize_text_field( $args['role'] ?? 'subscriber' );
		if ( ! get_role( $row_role ) ) {
			$row_role = 'subscriber';
		}
		if ( $row_role === 'administrator' ) {
			$creator = wp_get_current_user();
			if ( ! $creator || ! in_array( 'administrator', $creator->roles, true ) ) {
				return new \WP_Error(
					'role_denied',
					__( 'Only administrators may assign the administrator role.', 'wppowersuite-lite' )
				);
			}
		}
		// Role is stored on the login row and applied for the session via user_has_cap — never set_role() here.

		$args = FeatureGate::applyFreeTierCreateArgs( $args );

		$plain_token  = $this->generatePlainToken();
		$stored_token = $this->hashToken( $plain_token );

		// Calculate expiry
		$expires_at = $this->calculateExpiry( $args['expiry_type'] ?? 'day', $args['expiry_date'] ?? null );

		$data = [
			'token'                => $stored_token,
			'user_id'              => $user_id,
			'created_by'           => get_current_user_id(),
			'created_at'           => current_time( 'mysql' ),
			'expires_at'           => $expires_at,
			'single_use'           => ! empty( $args['single_use'] ) ? 1 : 0,
			'max_uses'             => isset( $args['max_uses'] ) ? absint( $args['max_uses'] ) : 0,
			'role'                 => $row_role,
			'allowed_capabilities' => $this->encodeLoginPermissionsFromArgs( $args ),
			'ip_restriction'       => ! empty( $args['ip_restriction'] ) ? sanitize_text_field( $args['ip_restriction'] ) : null,
			'bind_user_agent'      => ! empty( $args['bind_user_agent'] ) ? 1 : 0,
			'user_agent_hash'      => null,
			'redirect_to'           => ! empty( $args['redirect_to'] ) ? esc_url_raw( $args['redirect_to'] ) : null,
			'session_version'      => 0,
		];

		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$result = $wpdb->insert(
			$table_name,
			$data,
			[
				'%s', // token
				'%d', // user_id
				'%d', // created_by
				'%s', // created_at
				'%s', // expires_at
				'%d', // single_use
				'%d', // max_uses
				'%s', // role
				'%s', // allowed_capabilities
				'%s', // ip_restriction
				'%d', // bind_user_agent
				'%s', // user_agent_hash
				'%s', // redirect_to
				'%d', // session_version
			]
		);

		if ( $result === false ) {
			$detail = $wpdb->last_error ? ' ' . $wpdb->last_error : '';
			return new \WP_Error(
				'db_error',
				__( 'Failed to create temporary login.', 'wppowersuite-lite' ) . ( defined( 'WP_DEBUG' ) && WP_DEBUG ? $detail : '' )
			);
		}

		$login_id = $wpdb->insert_id;
		$this->rememberCopyableToken( $login_id, $plain_token );

		// Log creation
		$this->logActivity( $login_id, $user_id, 'login_created', $this->getClientIp(), $this->getUserAgent() );

		$created_login = $this->getLoginById( $login_id );
		if ( $created_login ) {
			TemporaryLoginEmail::queue(
				TemporaryLoginEmail::EVENT_CREATED,
				$created_login,
				[
					'actor_user_id' => get_current_user_id(),
					'client_ip'     => $this->getClientIp(),
					'client_ua'     => $this->getUserAgent(),
				]
			);
		}

		return [
			'id'    => $login_id,
			'token' => $plain_token,
		];
	}

	/**
	 * Replace the stored token hash and return the new plain token (invalidates the old URL).
	 *
	 * @param int  $login_id Login row ID.
	 * @param bool $notify   Whether to email the site owner about the rotation.
	 * @return string|\WP_Error Plain token or error.
	 */
	public function regenerateLoginToken( int $login_id, bool $notify = true ): string|\WP_Error {
		$login = $this->getLoginById( $login_id );
		if ( ! $login ) {
			return new \WP_Error( 'login_not_found', __( 'Login not found.', 'wppowersuite-lite' ) );
		}

		if ( ! empty( $login->is_revoked ) ) {
			return new \WP_Error( 'login_revoked', __( 'Cannot refresh a revoked login.', 'wppowersuite-lite' ) );
		}

		if ( $this->isExpired( $login ) ) {
			return new \WP_Error( 'login_expired', __( 'Cannot refresh an expired login.', 'wppowersuite-lite' ) );
		}

		$plain_token = $this->generatePlainToken();

		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$updated    = $wpdb->query(
			$wpdb->prepare(
				"UPDATE $table_name SET token = %s, session_version = session_version + 1 WHERE id = %d",
				$this->hashToken( $plain_token ),
				$login_id
			)
		);

		if ( $updated === false ) {
			return new \WP_Error( 'db_error', __( 'Failed to update login token.', 'wppowersuite-lite' ) );
		}

		$this->rememberCopyableToken( $login_id, $plain_token );
		$this->destroyBoundSessionsForLogin( $login_id, (int) $login->user_id );
		$this->logActivity( $login_id, (int) $login->user_id, 'token_regenerated', $this->getClientIp(), $this->getUserAgent() );

		if ( $notify ) {
			TemporaryLoginEmail::queue(
				TemporaryLoginEmail::EVENT_UPDATED,
				$login,
				[
					'actor_user_id' => get_current_user_id(),
					'client_ip'     => $this->getClientIp(),
					'client_ua'     => $this->getUserAgent(),
					'changes'       => [
						[
							'label' => __( 'Login URL', 'wppowersuite-lite' ),
							'value' => __( 'Regenerated (previous link invalidated)', 'wppowersuite-lite' ),
						],
					],
				]
			);
		}

		return $plain_token;
	}

	/**
	 * Return the current login URL for admin Copy without rotating the token.
	 *
	 * @param int $login_id Login row ID.
	 * @return string Empty when the reveal is missing, revoked, or expired.
	 */
	public function getCopyableLoginUrl( int $login_id ): string {
		$plain = $this->getCopyableToken( $login_id );
		if ( $plain === '' ) {
			return '';
		}

		return $this->getLoginUrl( $plain );
	}

	/**
	 * Resolve the current plain token for Copy. Rotates only when no reveal exists (legacy rows).
	 *
	 * @param int $login_id Login row ID.
	 * @return string|\WP_Error
	 */
	public function issueCopyableToken( int $login_id ): string|\WP_Error {
		$login = $this->getLoginById( $login_id );
		if ( ! $login ) {
			return new \WP_Error( 'login_not_found', __( 'Login not found.', 'wppowersuite-lite' ) );
		}

		if ( ! empty( $login->is_revoked ) ) {
			return new \WP_Error( 'login_revoked', __( 'Cannot copy a revoked login.', 'wppowersuite-lite' ) );
		}

		if ( $this->isExpired( $login ) ) {
			return new \WP_Error( 'login_expired', __( 'Cannot copy an expired login.', 'wppowersuite-lite' ) );
		}

		$cached = $this->getCopyableToken( $login_id );
		if ( $cached !== '' ) {
			return $cached;
		}

		return $this->regenerateLoginToken( $login_id, false );
	}

	/**
	 * Persist an encrypted admin-only copy of the latest plain token.
	 */
	private function rememberCopyableToken( int $login_id, string $plain_token ): void {
		if ( $login_id <= 0 || $plain_token === '' ) {
			return;
		}

		update_option( $this->copyRevealOptionKey( $login_id ), $this->encryptCopyableToken( $plain_token ), false );
	}

	/**
	 * Drop the admin Copy reveal when the login is revoked or deleted.
	 */
	private function forgetCopyableToken( int $login_id ): void {
		if ( $login_id <= 0 ) {
			return;
		}

		delete_option( $this->copyRevealOptionKey( $login_id ) );
	}

	private function copyRevealOptionKey( int $login_id ): string {
		return self::COPY_REVEAL_OPTION_PREFIX . $login_id;
	}

	/**
	 * Decrypt the cached token and confirm it still matches the stored HMAC.
	 */
	private function getCopyableToken( int $login_id ): string {
		if ( $login_id <= 0 ) {
			return '';
		}

		$stored = get_option( $this->copyRevealOptionKey( $login_id ), '' );
		if ( ! is_string( $stored ) || $stored === '' ) {
			return '';
		}

		$plain = $this->decryptCopyableToken( $stored );
		if ( $plain === '' ) {
			return '';
		}

		$login = $this->getLoginById( $login_id );
		if ( ! $login || ! empty( $login->is_revoked ) || $this->isExpired( $login ) ) {
			return '';
		}

		if ( ! hash_equals( (string) $login->token, $this->hashToken( $plain ) ) ) {
			$this->forgetCopyableToken( $login_id );
			return '';
		}

		return $plain;
	}

	private function encryptCopyableToken( string $plaintext ): string {
		$key = $this->copyRevealKey();
		if ( function_exists( 'sodium_crypto_secretbox' ) ) {
			$nonce  = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
			$cipher = sodium_crypto_secretbox( $plaintext, $nonce, $key );

			return self::COPY_REVEAL_ENC_PREFIX . base64_encode( $nonce . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		}

		$iv        = random_bytes( 16 );
		$encrypted = openssl_encrypt( $plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
		if ( $encrypted === false ) {
			return '';
		}

		return self::COPY_REVEAL_ENC_PREFIX . base64_encode( $iv . $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
	}

	private function decryptCopyableToken( string $stored ): string {
		if ( $stored === '' || ! str_starts_with( $stored, self::COPY_REVEAL_ENC_PREFIX ) ) {
			return '';
		}

		$raw = base64_decode( substr( $stored, strlen( self::COPY_REVEAL_ENC_PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		if ( ! is_string( $raw ) || $raw === '' ) {
			return '';
		}

		$key = $this->copyRevealKey();
		if ( function_exists( 'sodium_crypto_secretbox_open' ) ) {
			$nonce_len = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
			if ( strlen( $raw ) <= $nonce_len ) {
				return '';
			}
			$plain = sodium_crypto_secretbox_open( substr( $raw, $nonce_len ), substr( $raw, 0, $nonce_len ), $key );

			return $plain === false ? '' : (string) $plain;
		}

		if ( strlen( $raw ) <= 16 ) {
			return '';
		}

		$plain = openssl_decrypt( substr( $raw, 16 ), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, substr( $raw, 0, 16 ) );

		return $plain === false ? '' : (string) $plain;
	}

	/**
	 * @return string 32-byte key
	 */
	private function copyRevealKey(): string {
		return hash( 'sha256', $this->getSecretKey() . '|wpps_tl_copy|' . wp_salt( 'auth' ), true );
	}

	/**
	 * Get Login By Token
	 *
	 * Retrieves a login record by token.
	 *
	 * @since 1.0.0
	 * @param string $token Token.
	 * @return object|null Login object or null.
	 */
	private function getLoginByToken( string $token ): ?object {
		if ( $token === '' || strlen( $token ) > 128 ) {
			return null;
		}

		$this->ensureDatabaseTables();
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;

		$login = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM $table_name WHERE token = %s",
				$this->hashToken( $token )
			)
		);

		if ( $login ) {
			return $login;
		}

		// Legacy rows stored the URL token verbatim (pre-1.1.0) — migrate on successful match.
		$login = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM $table_name WHERE token = %s",
				$token
			)
		);

		if ( $login ) {
			$hashed = $this->hashToken( $token );
			$wpdb->update(
				$table_name,
				[ 'token' => $hashed ],
				[ 'id' => (int) $login->id ],
				[ '%s' ],
				[ '%d' ]
			);
			$login->token = $hashed;
		}

		return $login ?: null;
	}

	/**
	 * Get the active temporary-login session for the current user (avoids a table scan on most requests).
	 *
	 * @since 1.0.0
	 * @param int $user_id User ID.
	 * @return object|null Login object or null.
	 */
	/**
	 * Whether the current browser has an active temporary-login session.
	 *
	 * @param int $user_id Logged-in user ID.
	 * @return bool
	 */
	public function isCurrentBrowserTempSession( int $user_id = 0 ): bool {
		if ( $this->fail_closed_session ) {
			return true;
		}
		if ( $user_id <= 0 ) {
			$user_id = get_current_user_id();
		}
		if ( $user_id <= 0 ) {
			return false;
		}
		if ( $this->getSessionLoginIdFromCookie( $user_id ) > 0 ) {
			return true;
		}

		return $this->getBoundLoginIdForCurrentSession( $user_id ) > 0;
	}

	/**
	 * Whether this browser is still in a temporary-login session.
	 *
	 * Uses the signed invite cookie's user id, not only get_current_user_id(),
	 * so Login as User cannot drop TemporarySessionGuard. A present cookie
	 * that fails to parse fails closed.
	 */
	public function isBrowserTemporarySession(): bool {
		if ( $this->isCurrentBrowserTempSession( get_current_user_id() ) ) {
			return true;
		}

		if ( empty( $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) {
			return false;
		}

		$parsed = $this->parseSessionCookie( sanitize_text_field( wp_unslash( (string) $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) );
		if ( ! $parsed ) {
			return true;
		}

		$cookie_user_id = (int) $parsed['user_id'];
		if ( $cookie_user_id <= 0 || $cookie_user_id === get_current_user_id() ) {
			return false;
		}

		return $this->isCurrentBrowserTempSession( $cookie_user_id );
	}

	/**
	 * Remove legacy user-meta markers left by older versions (global per account).
	 *
	 * @return void
	 */
	public function cleanupLegacyGlobalSessionMeta(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( ! absint( get_user_meta( $user_id, self::USER_META_SESSION_LOGIN, true ) ) ) {
			return;
		}

		if ( $this->getSessionLoginIdFromCookie( $user_id ) > 0 ) {
			return;
		}

		delete_user_meta( $user_id, self::USER_META_SESSION_LOGIN );
	}

	private function hasTempSessionMeta( int $user_id ): bool {
		return $this->isCurrentBrowserTempSession( $user_id );
	}

	/**
	 * Bind this browser to a temporary-login row (signed cookie + in-request cache).
	 *
	 * @param object $login Active login row.
	 * @return void
	 */
	private function establishTemporarySession( object $login ): void {
		$this->setSessionCookie( $login );
		delete_user_meta( (int) $login->user_id, self::USER_META_SESSION_LOGIN );
		$this->session_login_cache     = $login;
		$this->session_role_caps_cache = null;
		$this->registerSessionEnforcementHooks();
	}

	private function getActiveSessionLogin( int $user_id ): ?object {
		if ( $this->session_login_cache !== null ) {
			return $this->session_login_cache;
		}

		$lid = $this->getSessionLoginIdFromCookie( $user_id );
		if ( $lid <= 0 ) {
			$lid = $this->getBoundLoginIdForCurrentSession( $user_id );
		}
		if ( $lid <= 0 ) {
			return null;
		}

		$login = $this->getLoginById( $lid );
		if (
			$login
			&& (int) $login->user_id === $user_id
			&& ! $login->is_revoked
			&& ! $this->isExpired( $login )
			&& $this->sessionBindingMatchesLogin( $login, $user_id )
		) {
			if ( $this->getBoundLoginIdForCurrentSession( $user_id ) <= 0 ) {
				$token = $this->getCurrentSessionToken();
				if ( $token !== '' ) {
					$this->bindSessionTokenForLogin( $login, $token );
				}
			}
			$this->session_login_cache = $login;
			return $login;
		}

		$this->invalidateBrokenTempSession( $user_id );
		return null;
	}

	/**
	 * Cookie version and/or bound session version must match the live row.
	 */
	private function sessionBindingMatchesLogin( object $login, int $user_id ): bool {
		$has_cookie = $this->getSessionLoginIdFromCookie( $user_id ) > 0;
		$cookie_ok  = $has_cookie && $this->sessionCookieMatchesLogin( $login );
		$bound_id   = $this->getBoundLoginIdForCurrentSession( $user_id );
		$bound_ok   = $bound_id === (int) $login->id && $this->boundSessionVersionMatches( $user_id, $login );

		if ( $has_cookie && ! $cookie_ok ) {
			return false;
		}
		if ( $bound_id > 0 && ! $bound_ok ) {
			return false;
		}

		return $cookie_ok || $bound_ok;
	}

	private function boundSessionVersionMatches( int $user_id, object $login ): bool {
		$token = $this->getCurrentSessionToken();
		if ( $token === '' ) {
			return false;
		}

		$hash = hash( 'sha256', $token );
		$map  = $this->getBoundSessionsMap( $user_id );
		if ( ! isset( $map[ $hash ] ) || ! is_array( $map[ $hash ] ) ) {
			return false;
		}

		return (int) ( $map[ $hash ]['login_id'] ?? 0 ) === (int) $login->id
			&& (int) ( $map[ $hash ]['ver'] ?? -1 ) === (int) ( $login->session_version ?? 0 );
	}

	private function invalidateBrokenTempSession( int $user_id ): void {
		$this->fail_closed_session = true;
		$this->unbindCurrentSessionToken( $user_id );
		$this->clearSessionCookie();
		$this->session_login_cache = null;
	}

	/**
	 * Capture the session token WordPress just minted for this invite login.
	 *
	 * @param string $logged_in_cookie Unused.
	 * @param int    $expire           Unused.
	 * @param int    $expiration       Unused.
	 * @param int    $user_id          Unused.
	 * @param string $scheme           Unused.
	 * @param string $token            Raw session token.
	 */
	public function captureLoggedInSessionToken( $logged_in_cookie, $expire, $expiration, $user_id, $scheme, $token = '' ): void {
		unset( $logged_in_cookie, $expire, $expiration, $user_id, $scheme );
		if ( is_string( $token ) && $token !== '' ) {
			$this->pending_bind_token = $token;
		}
	}

	/**
	 * Mark the minted WP session so revoke can find it even if bind meta is missing.
	 *
	 * @param array<string, mixed> $info Session information.
	 * @return array<string, mixed>
	 */
	public function attachTempLoginSessionInfo( array $info ): array {
		if ( is_object( $this->pending_bind_login ) ) {
			$info['wpps_tl_login_id']        = (int) $this->pending_bind_login->id;
			$info['wpps_tl_session_version'] = (int) ( $this->pending_bind_login->session_version ?? 0 );
		}

		return $info;
	}

	private function bindPendingOrCurrentSession( object $login ): bool {
		$token = $this->pending_bind_token;
		if ( $token === '' ) {
			$token = $this->getCurrentSessionToken();
		}
		$this->pending_bind_token = '';
		$this->pending_bind_login = null;
		if ( $token === '' ) {
			return false;
		}

		$this->bindSessionTokenForLogin( $login, $token );

		return $this->getBoundLoginIdForSessionToken( (int) $login->user_id, $token ) === (int) $login->id;
	}

	/**
	 * Bind a WordPress session token to a temporary-login row (stores sha256 only).
	 */
	public function bindSessionTokenForLogin( object $login, string $session_token ): void {
		$user_id = (int) $login->user_id;
		$token   = trim( $session_token );
		if ( $user_id <= 0 || $token === '' || (int) $login->id <= 0 ) {
			return;
		}

		$hash         = hash( 'sha256', $token );
		$map          = $this->getBoundSessionsMap( $user_id );
		$map[ $hash ] = [
			'login_id' => (int) $login->id,
			'ver'      => (int) ( $login->session_version ?? 0 ),
		];
		$this->saveBoundSessionsMap( $user_id, $map );
	}

	/**
	 * Re-attach a reminted WordPress session after Login as User switch-back.
	 *
	 * No temporary-login cookie means a real owner session — leave it alone,
	 * unless Login as User stamped that this switch started in a temp session.
	 * A live cookie re-binds the new token. A stale/revoked cookie ends only
	 * that reminted token so the contractor cannot keep an unbound admin session.
	 *
	 * @return 'bound'|'ended'|'ignored'
	 */
	public function adoptRemintedSession( int $user_id, string $session_token = '', bool $started_as_temp = false ): string {
		$user_id = absint( $user_id );
		$token   = trim( $session_token );
		if ( $user_id <= 0 ) {
			return 'ignored';
		}

		$cookie_login_id = $this->getSessionLoginIdFromCookie( $user_id );
		if ( $cookie_login_id <= 0 ) {
			if ( $started_as_temp ) {
				if ( $token !== '' ) {
					$this->destroySessionTokenForUser( $user_id, $token );
				}
				$this->clearSessionCookie();

				return 'ended';
			}

			return 'ignored';
		}

		$login     = $this->getLoginById( $cookie_login_id );
		$cookie_ok = $login
			&& (int) $login->user_id === $user_id
			&& empty( $login->is_revoked )
			&& ! $this->isExpired( $login )
			&& $this->sessionCookieMatchesLogin( $login );

		if ( $cookie_ok ) {
			if ( $token !== '' ) {
				$this->bindSessionTokenForLogin( $login, $token );
			}
			$this->establishTemporarySession( $login );
			return 'bound';
		}

		if ( $token !== '' ) {
			$this->destroySessionTokenForUser( $user_id, $token );
		}
		$this->clearSessionCookie();

		return 'ended';
	}

	/**
	 * @return int Login row ID, or 0.
	 */
	public function getBoundLoginIdForSessionToken( int $user_id, string $session_token ): int {
		if ( $user_id <= 0 || $session_token === '' ) {
			return 0;
		}

		$hash = hash( 'sha256', $session_token );
		$map  = $this->getBoundSessionsMap( $user_id );
		if ( ! isset( $map[ $hash ] ) || ! is_array( $map[ $hash ] ) ) {
			return 0;
		}

		return absint( $map[ $hash ]['login_id'] ?? 0 );
	}

	/**
	 * Destroy only WordPress sessions bound to this login row.
	 */
	public function destroyBoundSessionsForLogin( int $login_id, int $user_id = 0 ): void {
		if ( $login_id <= 0 ) {
			return;
		}
		if ( $user_id <= 0 ) {
			$login    = $this->getLoginById( $login_id );
			$user_id  = $login ? (int) $login->user_id : 0;
		}
		if ( $user_id <= 0 ) {
			return;
		}

		$map    = $this->getBoundSessionsMap( $user_id );
		$hashes = [];
		foreach ( $map as $hash => $row ) {
			if ( is_array( $row ) && (int) ( $row['login_id'] ?? 0 ) === $login_id ) {
				$hashes[] = (string) $hash;
				unset( $map[ $hash ] );
			}
		}

		$sessions = get_user_meta( $user_id, 'session_tokens', true );
		if ( ! is_array( $sessions ) ) {
			$sessions = [];
		}

		foreach ( $hashes as $hash ) {
			unset( $sessions[ $hash ] );
		}

		foreach ( $sessions as $hash => $info ) {
			if ( is_array( $info ) && (int) ( $info['wpps_tl_login_id'] ?? 0 ) === $login_id ) {
				unset( $sessions[ $hash ] );
				unset( $map[ $hash ] );
			}
		}

		if ( $sessions === [] ) {
			delete_user_meta( $user_id, 'session_tokens' );
		} else {
			update_user_meta( $user_id, 'session_tokens', $sessions );
		}

		$this->saveBoundSessionsMap( $user_id, $map );
	}

	private function destroySessionTokenForUser( int $user_id, string $session_token ): void {
		$token = trim( $session_token );
		if ( $user_id <= 0 || $token === '' ) {
			return;
		}

		$hash = hash( 'sha256', $token );
		$map  = $this->getBoundSessionsMap( $user_id );
		if ( isset( $map[ $hash ] ) ) {
			unset( $map[ $hash ] );
			$this->saveBoundSessionsMap( $user_id, $map );
		}

		$sessions = get_user_meta( $user_id, 'session_tokens', true );
		if ( ! is_array( $sessions ) ) {
			return;
		}

		unset( $sessions[ $hash ] );
		if ( $sessions === [] ) {
			delete_user_meta( $user_id, 'session_tokens' );
			return;
		}

		update_user_meta( $user_id, 'session_tokens', $sessions );
	}

	private function getBoundLoginIdForCurrentSession( int $user_id ): int {
		$token = $this->getCurrentSessionToken();
		if ( $token === '' ) {
			return 0;
		}

		$from_map = $this->getBoundLoginIdForSessionToken( $user_id, $token );
		if ( $from_map > 0 ) {
			return $from_map;
		}

		$sessions = get_user_meta( $user_id, 'session_tokens', true );
		if ( ! is_array( $sessions ) ) {
			return 0;
		}

		$hash = hash( 'sha256', $token );
		if ( ! isset( $sessions[ $hash ] ) || ! is_array( $sessions[ $hash ] ) ) {
			return 0;
		}

		return absint( $sessions[ $hash ]['wpps_tl_login_id'] ?? 0 );
	}

	private function getCurrentSessionToken(): string {
		$token = '';
		if ( function_exists( 'wp_get_session_token' ) ) {
			$token = (string) wp_get_session_token();
		}

		$filtered = apply_filters( 'wpps_tl_current_session_token', $token );
		return is_string( $filtered ) ? $filtered : '';
	}

	private function unbindCurrentSessionToken( int $user_id ): void {
		$token = $this->getCurrentSessionToken();
		if ( $user_id <= 0 || $token === '' ) {
			return;
		}

		$hash = hash( 'sha256', $token );
		$map  = $this->getBoundSessionsMap( $user_id );
		if ( isset( $map[ $hash ] ) ) {
			unset( $map[ $hash ] );
			$this->saveBoundSessionsMap( $user_id, $map );
		}
	}

	/**
	 * @return array<string, array{login_id:int, ver:int}>
	 */
	private function getBoundSessionsMap( int $user_id ): array {
		$raw = get_user_meta( $user_id, self::USER_META_BOUND_SESSIONS, true );
		if ( ! is_array( $raw ) ) {
			return [];
		}

		$clean = [];
		foreach ( $raw as $hash => $row ) {
			if ( ! is_string( $hash ) || $hash === '' || ! is_array( $row ) ) {
				continue;
			}
			$login_id = absint( $row['login_id'] ?? 0 );
			if ( $login_id <= 0 ) {
				continue;
			}
			$clean[ $hash ] = [
				'login_id' => $login_id,
				'ver'      => (int) ( $row['ver'] ?? 0 ),
			];
		}

		return $clean;
	}

	/**
	 * @param array<string, array{login_id:int, ver:int}> $map
	 */
	private function saveBoundSessionsMap( int $user_id, array $map ): void {
		if ( $map === [] ) {
			delete_user_meta( $user_id, self::USER_META_BOUND_SESSIONS );
			return;
		}

		update_user_meta( $user_id, self::USER_META_BOUND_SESSIONS, $map );
	}

	/**
	 * @param object $login Active login row.
	 */
	private function sessionCookieMatchesLogin( object $login ): bool {
		if ( empty( $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) {
			return false;
		}

		$parsed = $this->parseSessionCookie( sanitize_text_field( wp_unslash( $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) );
		if ( ! $parsed ) {
			return false;
		}

		return (int) $parsed['login_id'] === (int) $login->id
			&& (int) $parsed['session_version'] === (int) ( $login->session_version ?? 0 );
	}

	/**
	 * @param int $user_id Expected logged-in user ID.
	 * @return int Login row ID from cookie, or 0.
	 */
	private function getSessionLoginIdFromCookie( int $user_id = 0 ): int {
		if ( $user_id <= 0 ) {
			$user_id = get_current_user_id();
		}
		if ( $user_id <= 0 || empty( $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) {
			return 0;
		}

		$parsed = $this->parseSessionCookie( sanitize_text_field( wp_unslash( $_COOKIE[ self::SESSION_COOKIE_NAME ] ) ) );
		if ( ! $parsed || (int) $parsed['user_id'] !== $user_id ) {
			return 0;
		}

		return (int) $parsed['login_id'];
	}

	/**
	 * @return string Cookie value.
	 */
	private function buildSessionCookieValue( int $login_id, int $user_id, int $session_version ): string {
		$payload = $login_id . '|' . $user_id . '|' . $session_version;
		$sig     = hash_hmac( 'sha256', $payload, $this->getSecretKey() );

		return $login_id . '.' . $user_id . '.' . $session_version . '.' . $sig;
	}

	/**
	 * @return array{login_id:int,user_id:int,session_version:int}|null
	 */
	private function parseSessionCookie( string $cookie ): ?array {
		$parts = explode( '.', $cookie, 4 );
		if ( count( $parts ) === 4 ) {
			$login_id         = absint( $parts[0] );
			$user_id          = absint( $parts[1] );
			$session_version  = absint( $parts[2] );
			$sig              = $parts[3];

			if ( ! $login_id || ! $user_id || $sig === '' ) {
				return null;
			}

			$expected = hash_hmac( 'sha256', $login_id . '|' . $user_id . '|' . $session_version, $this->getSecretKey() );
			if ( ! hash_equals( $expected, $sig ) ) {
				return null;
			}

			return [
				'login_id'        => $login_id,
				'user_id'         => $user_id,
				'session_version' => $session_version,
			];
		}

		// Legacy cookie format: login_id.user_id.sig (session_version 0).
		$legacy = explode( '.', $cookie, 3 );
		if ( count( $legacy ) !== 3 ) {
			return null;
		}

		$login_id = absint( $legacy[0] );
		$user_id  = absint( $legacy[1] );
		$sig      = $legacy[2];

		if ( ! $login_id || ! $user_id || $sig === '' ) {
			return null;
		}

		$expected = hash_hmac( 'sha256', $login_id . '|' . $user_id, $this->getSecretKey() );
		if ( ! hash_equals( $expected, $sig ) ) {
			return null;
		}

		return [
			'login_id'        => $login_id,
			'user_id'         => $user_id,
			'session_version' => 0,
		];
	}

	/**
	 * @param object $login Active login row.
	 * @return void
	 */
	private function setSessionCookie( object $login ): void {
		$expires_ts = self::expiresAtToTimestamp( (string) $login->expires_at );
		if ( $expires_ts <= current_time( 'timestamp' ) ) {
			$expires_ts = current_time( 'timestamp' ) + HOUR_IN_SECONDS;
		}

		$value = $this->buildSessionCookieValue(
			(int) $login->id,
			(int) $login->user_id,
			(int) ( $login->session_version ?? 0 )
		);

		setcookie(
			self::SESSION_COOKIE_NAME,
			$value,
			$expires_ts,
			COOKIEPATH,
			COOKIE_DOMAIN,
			is_ssl(),
			true
		);

		$_COOKIE[ self::SESSION_COOKIE_NAME ] = $value;
	}

	/**
	 * @return void
	 */
	private function clearSessionCookie(): void {
		setcookie(
			self::SESSION_COOKIE_NAME,
			'',
			time() - YEAR_IN_SECONDS,
			COOKIEPATH,
			COOKIE_DOMAIN,
			is_ssl(),
			true
		);

		unset( $_COOKIE[ self::SESSION_COOKIE_NAME ] );
	}

	/**
	 * @param int $user_id User ID.
	 * @return void
	 */
	private function clearTemporarySession( int $user_id ): void {
		$this->clearSessionCookie();
		delete_user_meta( $user_id, self::USER_META_SESSION_LOGIN );
	}

	/**
	 * Clear session marker so the next request skips temporary-login enforcement.
	 *
	 * @since 1.0.0
	 * @param mixed $user_id Passed by WordPress wp_logout when supported.
	 * @return void
	 */
	public function onWpLogoutClearSessionMeta( $user_id = 0 ): void {
		$uid = absint( $user_id );
		if ( ! $uid ) {
			$uid = get_current_user_id();
		}
		if ( $uid ) {
			$this->unbindCurrentSessionToken( $uid );
			$this->clearTemporarySession( $uid );
		} else {
			$this->clearSessionCookie();
		}
	}

	private function allowTokenAttemptFromIp(): bool {
		$attempts = (int) get_transient( self::TOKEN_RATE_TRANSIENT_PREFIX . md5( $this->getRemoteAddrStrict() ) );
		return $attempts < 5;
	}

	private function bumpTokenFailure(): void {
		$key      = self::TOKEN_RATE_TRANSIENT_PREFIX . md5( $this->getRemoteAddrStrict() );
		$attempts = (int) get_transient( $key );
		set_transient( $key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
	}

	private function clearTokenRateLimitForIp(): void {
		delete_transient( self::TOKEN_RATE_TRANSIENT_PREFIX . md5( $this->getRemoteAddrStrict() ) );
	}

	/**
	 * Remote address without trusting proxy headers (used for brute-force throttling).
	 */
	private function getRemoteAddrStrict(): string {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0';
	}

	/**
	 * Optional admin email when a temporary link completes sign-in (disable via filter wpps_tl_notify_admin_on_login).
	 */
	private function maybeNotifyAdminLoginUsed( object $login, \WP_User $user ): void {
		TemporaryLoginEmail::queue(
			TemporaryLoginEmail::EVENT_USED,
			$login,
			[
				'actor_user_id' => (int) $user->ID,
				'client_ip'     => $this->getClientIp(),
				'client_ua'     => $this->getUserAgent(),
			]
		);
	}

	/**
	 * Check if Login is Expired
	 *
	 * @since 1.0.0
	 * @param object $login Login object.
	 * @return bool True if expired.
	 */
	/**
	 * Convert a site-local MySQL datetime (as stored in expires_at) to a Unix timestamp.
	 *
	 * @param string $expires_at MySQL datetime in site timezone.
	 * @return int
	 */
	public static function expiresAtToTimestamp( string $expires_at ): int {
		if ( trim( $expires_at ) === '' ) {
			return 0;
		}
		return (int) mysql2date( 'U', $expires_at, false );
	}

	/**
	 * Whether a login row's expires_at is in the past (site timezone).
	 *
	 * @param string $expires_at MySQL datetime from tl_temporary_logins.expires_at.
	 * @return bool
	 */
	public static function isLoginExpired( string $expires_at ): bool {
		if ( trim( $expires_at ) === '' ) {
			return false;
		}
		return self::expiresAtToTimestamp( $expires_at ) < current_time( 'timestamp' );
	}

	/**
	 * @param object $login Login row.
	 * @return bool
	 */
	private function isExpired( object $login ): bool {
		return self::isLoginExpired( (string) $login->expires_at );
	}

	/**
	 * End a broken or expired temporary session (logout + redirect).
	 *
	 * @param bool $had_temp_session Whether the user had temp session meta before validation.
	 * @return void
	 */
	private function forceEndTemporarySession( bool $had_temp_session ): void {
		if ( ! $had_temp_session || ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		$bound   = $this->getBoundLoginIdForCurrentSession( $user_id );
		$this->unbindCurrentSessionToken( $user_id );
		$this->clearTemporarySession( $user_id );
		if ( $bound <= 0 ) {
			return;
		}

		wp_logout();
		wp_safe_redirect( wp_login_url() );
		exit;
	}

	/**
	 * End a revoked/expired invite: destroy the bound WP session, or only drop the hint cookie.
	 */
	private function endBoundOrCookieSession( int $user_id, int $login_id ): void {
		$logout = $this->getBoundLoginIdForCurrentSession( $user_id ) === $login_id;
		$this->destroyBoundSessionsForLogin( $login_id, $user_id );
		$this->clearTemporarySession( $user_id );
		if ( ! $logout ) {
			return;
		}

		wp_logout();
		wp_safe_redirect( home_url() );
		exit;
	}

	/**
	 * Check IP Restriction
	 *
	 * @since 1.0.0
	 * @param string $restriction IP restriction (single IP or CIDR range).
	 * @return bool True if IP is allowed.
	 */
	/**
	 * IP used for login restrictions (filterable; defaults to REMOTE_ADDR).
	 *
	 * @return string
	 */
	private function getIpForRestrictionCheck(): string {
		$ip = $this->getRemoteAddrStrict();
		return (string) apply_filters( 'wpps_tl_restriction_client_ip', $ip );
	}

	/**
	 * @param string $restriction IP or CIDR.
	 * @param string $client_ip   Client IP to test.
	 * @return bool
	 */
	private function checkIpRestriction( string $restriction, string $client_ip ): bool {

		if ( strpos( $restriction, '/' ) !== false ) {
			return $this->ipInRange( $client_ip, $restriction );
		}

		// Exact match
		return $client_ip === $restriction;
	}

	/**
	 * Check if IP is in CIDR Range
	 *
	 * @since 1.0.0
	 * @param string $ip IP address.
	 * @param string $range CIDR range.
	 * @return bool True if IP is in range.
	 */
	private function ipInRange( string $ip, string $range ): bool {
		if ( strpos( $range, '/' ) === false ) {
			return $ip === $range;
		}

		list( $subnet, $mask ) = explode( '/', $range, 2 );
		$mask = (int) $mask;

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			if ( $mask < 0 || $mask > 32 ) {
				return false;
			}
			$ip_long     = ip2long( $ip );
			$subnet_long = ip2long( $subnet );
			if ( $ip_long === false || $subnet_long === false ) {
				return false;
			}
			$mask_long = -1 << ( 32 - $mask );
			return ( $ip_long & $mask_long ) === ( $subnet_long & $mask_long );
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			if ( $mask < 0 || $mask > 128 ) {
				return false;
			}
			$ip_bin     = inet_pton( $ip );
			$subnet_bin = inet_pton( $subnet );
			if ( $ip_bin === false || $subnet_bin === false ) {
				return false;
			}
			$bytes = (int) floor( $mask / 8 );
			$bits  = $mask % 8;
			if ( $bytes > 0 && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) {
				return false;
			}
			if ( $bits === 0 ) {
				return true;
			}
			$ip_byte     = ord( $ip_bin[ $bytes ] );
			$subnet_byte = ord( $subnet_bin[ $bytes ] );
			$mask_byte   = ( 0xFF << ( 8 - $bits ) ) & 0xFF;
			return ( $ip_byte & $mask_byte ) === ( $subnet_byte & $mask_byte );
		}

		return false;
	}

	/**
	 * Get Client IP
	 *
	 * @since 1.0.0
	 * @return string IP address.
	 */
	private function getClientIp(): string {
		$ip_keys = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' ];
		foreach ( $ip_keys as $key ) {
			if ( ! empty( $_SERVER[ $key ] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
				if ( strpos( $ip, ',' ) !== false ) {
					$ip = trim( explode( ',', $ip )[0] );
				}
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}
		return '0.0.0.0';
	}

	/**
	 * Get User Agent
	 *
	 * @since 1.0.0
	 * @return string User agent.
	 */
	private function getUserAgent(): string {
		return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
	}

	/**
	 * Get Current Page
	 *
	 * @since 1.0.0
	 * @return string Current page URL.
	 */
	private function getCurrentPage(): string {
		$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
		$uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		if ( $host === '' ) {
			return $uri;
		}
		$protocol = is_ssl() ? 'https://' : 'http://';
		return $protocol . $host . $uri;
	}

	/**
	 * Calculate Expiry
	 *
	 * Calculates expiry datetime based on type and optional date.
	 *
	 * @since 1.0.0
	 * @param string      $type Expiry type.
	 * @param string|null $date Optional custom date.
	 * @return string MySQL datetime.
	 */
	private function calculateExpiry( string $type, ?string $date = null ): string {
		if ( $type === 'custom' && ! empty( $date ) ) {
			$timestamp = strtotime( $date );
			return $timestamp ? wp_date( 'Y-m-d H:i:s', $timestamp ) : current_time( 'mysql' );
		}

		$durations = [
			'hour'      => HOUR_IN_SECONDS,
			'3_hours'   => HOUR_IN_SECONDS * 3,
			'day'       => DAY_IN_SECONDS,
			'3_days'    => DAY_IN_SECONDS * 3,
			'week'      => WEEK_IN_SECONDS,
			'month'     => MONTH_IN_SECONDS,
		];

		$seconds = $durations[ $type ] ?? DAY_IN_SECONDS;
		return wp_date( 'Y-m-d H:i:s', current_time( 'timestamp' ) + $seconds );
	}

	/**
	 * Generate Username
	 *
	 * Generates a unique username from email or a random string.
	 *
	 * @since 1.0.0
	 * @param string $email_or_base Email address or base string.
	 * @return string Username.
	 */
	private function generateUsername( string $email_or_base ): string {
		// If it looks like an email, extract the base
		if ( strpos( $email_or_base, '@' ) !== false ) {
			$base = sanitize_user( explode( '@', $email_or_base )[0], true );
		} else {
			$base = sanitize_user( $email_or_base, true );
		}
		
		// If base is empty, use a random string
		if ( empty( $base ) ) {
			$base = 'temp';
		}
		
		$username = 'tl-' . $base . '-' . wp_generate_password( 8, false );
		$counter = 1;
		while ( username_exists( $username ) ) {
			$username = 'tl-' . $base . '-' . wp_generate_password( 8, false );
			$counter++;
			if ( $counter > 100 ) {
				$username = 'tl-' . wp_generate_password( 16, false );
				break;
			}
		}
		return $username;
	}

	/**
	 * Atomically claim one use of a temporary login before authenticating.
	 *
	 * Enforces single-use / max-uses under concurrency: the UPDATE itself is the
	 * gate, so two parallel requests cannot both observe used_count=0 and proceed.
	 *
	 * @param object $login Login row.
	 * @return bool True when this request owns a use slot.
	 */
	private function claimLoginUseSlot( object $login ): bool {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$login_id   = (int) $login->id;

		if ( ! empty( $login->single_use ) ) {
			$updated = $wpdb->query(
				$wpdb->prepare(
					"UPDATE $table_name SET used_count = used_count + 1 WHERE id = %d AND used_count = 0",
					$login_id
				)
			);
			return $updated === 1;
		}

		$max_uses = (int) ( $login->max_uses ?? 0 );
		if ( $max_uses > 0 ) {
			$updated = $wpdb->query(
				$wpdb->prepare(
					"UPDATE $table_name SET used_count = used_count + 1 WHERE id = %d AND used_count < %d",
					$login_id,
					$max_uses
				)
			);
			return $updated === 1;
		}

		$wpdb->query(
			$wpdb->prepare(
				"UPDATE $table_name SET used_count = used_count + 1 WHERE id = %d",
				$login_id
			)
		);
		return true;
	}

	/**
	 * Update Login User Agent
	 *
	 * @since 1.0.0
	 * @param int    $login_id Login ID.
	 * @param string $user_agent_hash User agent hash.
	 * @return void
	 */
	private function updateLoginUserAgent( int $login_id, string $user_agent_hash ): void {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$wpdb->update(
			$table_name,
			[ 'user_agent_hash' => $user_agent_hash ],
			[ 'id' => $login_id ],
			[ '%s' ],
			[ '%d' ]
		);
	}


	/**
	 * Redirect After Login
	 *
	 * @since 1.0.0
	 * @param object $login Login object.
	 * @return void
	 */
	private function redirectAfterLogin( object $login ): void {
		$user      = get_userdata( (int) $login->user_id );
		$role_slug = sanitize_text_field( $login->role ?? 'subscriber' );

		if ( ! empty( $login->redirect_to ) ) {
			$redirect_to = remove_query_arg( self::TOKEN_PARAM, $login->redirect_to );
		} elseif ( $this->roleCanAccessDashboard( $role_slug ) ) {
			$redirect_to = admin_url();
		} else {
			$redirect_to = home_url( '/' );
		}

		$requested_redirect_to = '';
		if ( ! empty( $_GET['redirect_to'] ) ) {
			$requested_redirect_to = esc_url_raw( wp_unslash( $_GET['redirect_to'] ) );
		}

		if ( $user instanceof \WP_User ) {
			if ( empty( $login->redirect_to ) && $this->roleCanAccessDashboard( $role_slug ) ) {
				// WooCommerce and similar plugins redirect subscribers to my-account; keep editors/admins in wp-admin.
				add_filter( 'login_redirect', static fn() => admin_url(), 9999 );
				add_filter( 'woocommerce_login_redirect', static fn() => admin_url(), 9999 );
			} else {
				$this->registerRedirectAfterLoginHooks();
				/**
				 * Same filter chain as wp-login (Redirect After Login module hooks here).
				 *
				 * @param string   $redirect_to           Default redirect URL.
				 * @param string   $requested_redirect_to redirect_to query arg from the magic link, if any.
				 * @param \WP_User $user                  Authenticated user.
				 */
				$redirect_to = apply_filters( 'login_redirect', $redirect_to, $requested_redirect_to, $user );
				if ( class_exists( 'WooCommerce' ) ) {
					$redirect_to = apply_filters( 'woocommerce_login_redirect', (string) $redirect_to, $user );
				}
			}
		}

		wp_safe_redirect( wp_validate_redirect( $redirect_to, admin_url() ) );
		exit;
	}

	/**
	 * Whether a temporary session role should land in wp-admin after login.
	 *
	 * @param string $role_slug WordPress role slug stored on the login row.
	 */
	private function roleCanAccessDashboard( string $role_slug ): bool {
		$role = get_role( $role_slug );
		if ( ! $role ) {
			return false;
		}

		$caps = $role->capabilities;

		return ! empty( $caps['manage_options'] ) || ! empty( $caps['edit_posts'] );
	}

	/**
	 * Register Redirect After Login filters for token-based sign-in (no wp-login.php).
	 *
	 * @return void
	 */
	private function registerRedirectAfterLoginHooks(): void {
		if ( ! class_exists( 'WP_PowerSuite\\Core\\ModuleManager' ) ) {
			return;
		}

		$module = \WP_PowerSuite\Core\ModuleManager::getInstance()->getModule( 'redirect-after-login' );
		if ( $module instanceof \RedirectAfterLogin\Module && $module->isEnabled() ) {
			$module->registerRedirectHooks();
		}
	}

	/**
	 * Log Activity
	 *
	 * Logs an activity event.
	 *
	 * @since 1.0.0
	 * @param int    $login_id Login ID.
	 * @param int    $user_id User ID.
	 * @param string $action Action type.
	 * @param string $ip_address IP address.
	 * @param string $user_agent User agent.
	 * @param string $page_accessed Optional page accessed.
	 * @return void
	 */
	private function logActivity( int $login_id, int $user_id, string $action, string $ip_address, string $user_agent, string $page_accessed = '' ): void {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_LOG_TABLE_NAME;
		$wpdb->insert(
			$table_name,
			[
				'login_id'      => $login_id,
				'user_id'       => $user_id,
				'action'        => $action,
				'ip_address'    => $ip_address,
				'user_agent'    => $user_agent,
				'page_accessed' => $this->redactSensitiveUrlForLog( $page_accessed ),
				'logged_at'     => current_time( 'mysql' ),
			],
			[ '%d', '%d', '%s', '%s', '%s', '%s', '%s' ]
		);
	}

	/**
	 * Remove secrets from URLs before storing or displaying activity log entries.
	 *
	 * Magic-link tokens must never be persisted in tl_activity_logs — only hashed in tl_temporary_logins.
	 */
	private function redactSensitiveUrlForLog( string $url ): string {
		if ( $url === '' ) {
			return '';
		}

		$redacted = remove_query_arg( self::TOKEN_PARAM, $url );

		return rtrim( $redacted, '?' );
	}

	/**
	 * Get All Logins
	 *
	 * Gets all temporary logins with optional filters.
	 *
	 * @since 1.0.0
	 * @param array $args Query arguments.
	 * @return array Login records.
	 */
	public function getAllLogins( array $args = [] ): array {
		$this->ensureDatabaseTables();
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;

		$where = [ '1=1' ];
		$where_values = [];

		if ( ! empty( $args['status'] ) ) {
			if ( $args['status'] === 'active' ) {
				$where[] = 'is_revoked = 0 AND expires_at > %s';
				$where_values[] = current_time( 'mysql' );
			} elseif ( $args['status'] === 'expired' ) {
				$where[] = 'expires_at <= %s';
				$where_values[] = current_time( 'mysql' );
			} elseif ( $args['status'] === 'revoked' ) {
				$where[] = 'is_revoked = 1';
			}
		}

		$where_sql = implode( ' AND ', $where );
		$query     = "SELECT * FROM `{$table_name}` WHERE {$where_sql} ORDER BY created_at DESC";

		if ( $where_values === [] ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table is prefix + plugin constant; WHERE is hardcoded.
			return $wpdb->get_results( $query );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table is prefix + plugin constant; WHERE uses %s placeholders.
		return $wpdb->get_results( $wpdb->prepare( $query, ...$where_values ) );
	}

	/**
	 * Status counts for admin list filters (single query).
	 *
	 * @return array{all:int,active:int,expired:int,revoked:int}
	 */
	public function getLoginCounts(): array {
		$this->ensureDatabaseTables();
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$now        = current_time( 'mysql' );

		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- table is prefix + plugin constant.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT
					COUNT(*) AS total,
					SUM( CASE WHEN is_revoked = 0 AND expires_at > %s THEN 1 ELSE 0 END ) AS active,
					SUM( CASE WHEN is_revoked = 0 AND expires_at <= %s THEN 1 ELSE 0 END ) AS expired,
					SUM( CASE WHEN is_revoked = 1 THEN 1 ELSE 0 END ) AS revoked
				FROM `{$table_name}`",
				$now,
				$now
			),
			ARRAY_A
		);

		return [
			'all'     => isset( $row['total'] ) ? (int) $row['total'] : 0,
			'active'  => isset( $row['active'] ) ? (int) $row['active'] : 0,
			'expired' => isset( $row['expired'] ) ? (int) $row['expired'] : 0,
			'revoked' => isset( $row['revoked'] ) ? (int) $row['revoked'] : 0,
		];
	}

	/**
	 * Revoke Login
	 *
	 * Revokes a temporary login.
	 *
	 * @since 1.0.0
	 * @param int $login_id Login ID.
	 * @return bool Success.
	 */
	public function revokeLogin( int $login_id ): bool {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$result = $wpdb->query(
			$wpdb->prepare(
				"UPDATE $table_name SET is_revoked = 1, revoked_at = %s, revoked_by = %d WHERE id = %d",
				current_time( 'mysql' ),
				get_current_user_id(),
				$login_id
			)
		);

		if ( $result !== false ) {
			$this->forgetCopyableToken( $login_id );
			$login = $this->getLoginById( $login_id );
			if ( $login ) {
				$this->logActivity( $login_id, $login->user_id, 'login_revoked', $this->getClientIp(), $this->getUserAgent() );
				$this->terminateSessionsForLogin( $login_id );
				TemporaryLoginEmail::queue(
					TemporaryLoginEmail::EVENT_REVOKED,
					$login,
					[
						'actor_user_id' => (int) ( $login->revoked_by ?? get_current_user_id() ),
						'client_ip'     => $this->getClientIp(),
						'client_ua'     => $this->getUserAgent(),
					]
				);
			}
		}

		return $result !== false;
	}

	/**
	 * End every session tied to a login row (revoke / delete).
	 *
	 * @param int $login_id Login row ID.
	 * @return void
	 */
	private function terminateSessionsForLogin( int $login_id ): void {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE $table_name SET session_version = session_version + 1 WHERE id = %d",
				$login_id
			)
		);

		$login   = $this->getLoginById( $login_id );
		$user_id = $login ? (int) $login->user_id : 0;
		$logout  = $user_id > 0 && $this->getBoundLoginIdForCurrentSession( $user_id ) === $login_id;
		if ( $user_id > 0 ) {
			$this->destroyBoundSessionsForLogin( $login_id, $user_id );
		}

		$users = get_users(
			[
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- find users bound to one temporary login.
				'meta_key'   => self::USER_META_SESSION_LOGIN,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- scoped to a single login id.
				'meta_value' => (string) $login_id,
				'fields'     => 'ID',
			]
		);

		foreach ( $users as $uid ) {
			delete_user_meta( absint( $uid ), self::USER_META_SESSION_LOGIN );
		}

		if ( $this->getSessionLoginIdFromCookie() === $login_id ) {
			$this->clearSessionCookie();
		}
		if ( $logout ) {
			$this->clearTemporarySession( get_current_user_id() );
			wp_logout();
		}
	}

	/**
	 * Re-enables a revoked login and rotates the token (invalidates old URLs and sessions).
	 *
	 * @since 1.0.0
	 * @param int $login_id Login ID.
	 * @return bool Success.
	 */
	public function enableLogin( int $login_id ): bool {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;

		$login = $this->getLoginById( $login_id );
		if ( ! $login ) {
			return false;
		}

		if ( $this->isExpired( $login ) ) {
			return false;
		}

		$result = $wpdb->update(
			$table_name,
			[
				'is_revoked' => 0,
				'revoked_at' => null,
				'revoked_by' => null,
			],
			[ 'id' => $login_id ],
			[ '%d', '%s', '%d' ],
			[ '%d' ]
		);

		if ( $result === false ) {
			return false;
		}

		$plain = $this->regenerateLoginToken( $login_id );
		if ( is_wp_error( $plain ) ) {
			return false;
		}

		$this->logActivity( $login_id, (int) $login->user_id, 'login_enabled', $this->getClientIp(), $this->getUserAgent() );

		return true;
	}

	/**
	 * Delete Login
	 *
	 * Permanently deletes a temporary login.
	 *
	 * @since 1.0.0
	 * @param int $login_id Login ID.
	 * @return bool Success.
	 */
	public function deleteLogin( int $login_id ): bool {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$log_table_name = $wpdb->prefix . self::DB_LOG_TABLE_NAME;

		$login = $this->getLoginById( $login_id );
		$user_id = $login ? (int) $login->user_id : 0;
		$this->forgetCopyableToken( $login_id );

		$wpdb->delete( $log_table_name, [ 'login_id' => $login_id ], [ '%d' ] );

		$this->terminateSessionsForLogin( $login_id );

		$result = $wpdb->delete( $table_name, [ 'id' => $login_id ], [ '%d' ] );

		if ( $result !== false && $user_id > 0 ) {
			$this->maybeDeleteOrphanPasswordlessUser( $user_id );
		}

		return $result !== false;
	}

	/**
	 * Update Login
	 *
	 * Updates an existing temporary login.
	 *
	 * @since 1.0.0
	 * @param int   $login_id Login ID.
	 * @param array $args Update arguments.
	 * @return bool|\WP_Error Success or error.
	 */
	public function updateLogin( int $login_id, array $args ): bool|\WP_Error {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;

		$login = $this->getLoginById( $login_id );
		if ( ! $login ) {
			return new \WP_Error( 'login_not_found', __( 'Login not found.', 'wppowersuite-lite' ) );
		}

		$args = FeatureGate::applyFreeTierUpdateArgs( $args, $login );

		$update_data = [];

		if ( isset( $args['role'] ) ) {
			$new_role = sanitize_text_field( $args['role'] );
			if ( ! get_role( $new_role ) ) {
				return new \WP_Error( 'invalid_role', __( 'Invalid role.', 'wppowersuite-lite' ) );
			}
			if ( $new_role === 'administrator' ) {
				$creator = wp_get_current_user();
				if ( ! $creator || ! in_array( 'administrator', $creator->roles, true ) ) {
					return new \WP_Error(
						'role_denied',
						__( 'Only administrators may assign the administrator role.', 'wppowersuite-lite' )
					);
				}
			}
			$update_data['role'] = $new_role;
		}

		if ( isset( $args['expiry_type'] ) || isset( $args['expiry_date'] ) ) {
			$expiry_type = $args['expiry_type'] ?? 'day';
			$expiry_date = $args['expiry_date'] ?? null;
			$update_data['expires_at'] = $this->calculateExpiry( $expiry_type, $expiry_date );
		}

		if ( isset( $args['single_use'] ) ) {
			$update_data['single_use'] = $args['single_use'] ? 1 : 0;
		}

		if ( isset( $args['max_uses'] ) ) {
			$update_data['max_uses'] = absint( $args['max_uses'] );
		}

		if ( isset( $args['ip_restriction'] ) ) {
			$update_data['ip_restriction'] = ! empty( $args['ip_restriction'] ) ? sanitize_text_field( $args['ip_restriction'] ) : null;
		}

		if ( isset( $args['redirect_to'] ) ) {
			$update_data['redirect_to'] = ! empty( $args['redirect_to'] ) ? esc_url_raw( $args['redirect_to'] ) : null;
		}

		if ( array_key_exists( 'bind_user_agent', $args ) ) {
			$update_data['bind_user_agent'] = ! empty( $args['bind_user_agent'] ) ? 1 : 0;
			if ( empty( $args['bind_user_agent'] ) ) {
				$update_data['user_agent_hash'] = null;
			}
		}

		if ( $this->argsIncludePermissionFields( $args ) ) {
			$update_data['allowed_capabilities'] = $this->encodeLoginPermissionsFromArgs( $args );
		}

		if ( empty( $update_data ) ) {
			return true; // Nothing to update
		}

		$changes = TemporaryLoginEmail::buildUpdateChanges( $login, $update_data );

		$result = $wpdb->update(
			$table_name,
			$update_data,
			[ 'id' => $login_id ],
			array_fill( 0, count( $update_data ), '%s' ),
			[ '%d' ]
		);

		if ( $result !== false ) {
			$this->logActivity( $login_id, $login->user_id, 'login_updated', $this->getClientIp(), $this->getUserAgent() );
			$updated_login = $this->getLoginById( $login_id );
			if ( $updated_login && ! empty( $changes ) ) {
				TemporaryLoginEmail::queue(
					TemporaryLoginEmail::EVENT_UPDATED,
					$updated_login,
					[
						'actor_user_id' => get_current_user_id(),
						'client_ip'     => $this->getClientIp(),
						'client_ua'     => $this->getUserAgent(),
						'changes'       => $changes,
					]
				);
			}
		}

		return $result !== false;
	}

	/**
	 * Get Login By ID
	 *
	 * @since 1.0.0
	 * @param int $login_id Login ID.
	 * @return object|null Login object.
	 */
	public function getLoginById( int $login_id ): ?object {
		$this->ensureDatabaseTables();
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;

		return $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $login_id )
		) ?: null;
	}

	/**
	 * Expire Login
	 *
	 * Sets the expiry time to now so the login becomes expired immediately.
	 *
	 * @since 1.0.0
	 * @param int $login_id Login ID.
	 * @return bool Success.
	 */
	public function expireLogin( int $login_id ): bool {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		return $wpdb->update(
			$table_name,
			[ 'expires_at' => current_time( 'mysql' ) ],
			[ 'id' => $login_id ],
			[ '%s' ],
			[ '%d' ]
		) !== false;
	}

	/**
	 * Extend Expiry
	 *
	 * Extends the expiry time for a login.
	 *
	 * @since 1.0.0
	 * @param int    $login_id Login ID.
	 * @param string $new_expiry New expiry datetime.
	 * @return bool Success.
	 */
	public function extendExpiry( int $login_id, string $new_expiry ): bool {
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		return $wpdb->update(
			$table_name,
			[ 'expires_at' => $new_expiry ],
			[ 'id' => $login_id ],
			[ '%s' ],
			[ '%d' ]
		) !== false;
	}

	/**
	 * Get Activity Logs
	 *
	 * Gets activity logs for a login or all logins.
	 *
	 * @since 1.0.0
	 * @param array $args Query arguments.
	 * @return array Log records.
	 */
	public function getActivityLogs( array $args = [] ): array {
		$this->ensureDatabaseTables();
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_LOG_TABLE_NAME;

		$where = [ '1=1' ];
		$where_values = [];

		if ( ! empty( $args['login_id'] ) ) {
			$where[] = 'login_id = %d';
			$where_values[] = $args['login_id'];
		}

		if ( ! empty( $args['user_id'] ) ) {
			$where[] = 'user_id = %d';
			$where_values[] = $args['user_id'];
		}

		if ( ! empty( $args['exclude_noise'] ) ) {
			$where[] = "action NOT IN ('page_access', 'login_created')";
		}

		$where_sql = implode( ' AND ', $where );
		$limit_sql = '';
		if ( ! empty( $args['limit'] ) ) {
			$limit_sql      = ' LIMIT %d';
			$where_values[] = absint( $args['limit'] );
		}

		$query = "SELECT * FROM `{$table_name}` WHERE {$where_sql} ORDER BY logged_at DESC{$limit_sql}";
		if ( $where_values === [] ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table is prefix + plugin constant; WHERE is hardcoded 1=1.
			$results = $wpdb->get_results( $query );
		} else {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table is prefix + plugin constant; WHERE/LIMIT use placeholders.
			$results = $wpdb->get_results( $wpdb->prepare( $query, ...$where_values ) );
		}
		if ( ! $results ) {
			return [];
		}

		foreach ( $results as $log ) {
			if ( ! empty( $log->page_accessed ) ) {
				$log->page_accessed = $this->redactSensitiveUrlForLog( (string) $log->page_accessed );
			}
		}

		return $results;
	}

	/**
	 * Cleanup Expired Logins
	 *
	 * Removes expired login records (cron job).
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function cleanupExpiredLogins(): void {
		if ( ! $this->isEnabled() ) {
			return;
		}
		$this->ensureDatabaseTables();
		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$now        = current_time( 'mysql' );

		$expired_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, user_id FROM $table_name WHERE expires_at < %s AND is_revoked = 0",
				$now
			)
		);

		$expired_user_ids = [];
		if ( is_array( $expired_rows ) ) {
			foreach ( $expired_rows as $row ) {
				$this->destroyBoundSessionsForLogin( (int) $row->id, (int) $row->user_id );
				$expired_user_ids[] = (int) $row->user_id;
			}
		}

		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table_name WHERE expires_at < %s AND is_revoked = 0",
				$now
			)
		);

		if ( is_array( $expired_user_ids ) ) {
			foreach ( $expired_user_ids as $user_id ) {
				$user_id = absint( $user_id );
				if ( $user_id > 0 ) {
					$this->maybeDeleteOrphanPasswordlessUser( $user_id );
				}
			}
		}
	}

	/**
	 * Remove link-only users that no longer have any login rows.
	 */
	private function maybeDeleteOrphanPasswordlessUser( int $user_id ): void {
		if ( ! $this->mustUseTemporaryLink( $user_id ) ) {
			return;
		}

		global $wpdb;
		$table_name = $wpdb->prefix . self::DB_TABLE_NAME;
		$remaining  = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM $table_name WHERE user_id = %d",
				$user_id
			)
		);

		if ( $remaining === 0 ) {
			require_once ABSPATH . 'wp-admin/includes/user.php';
			wp_delete_user( $user_id );
		}
	}

	/**
	 * Prune activity logs older than {@see LOG_RETENTION_DAYS}.
	 */
	public function cleanupOldActivityLogs(): void {
		if ( ! $this->isEnabled() ) {
			return;
		}
		$this->ensureDatabaseTables();
		global $wpdb;
		$log_table = $wpdb->prefix . self::DB_LOG_TABLE_NAME;
		$cutoff    = wp_date( 'Y-m-d H:i:s', current_time( 'timestamp' ) - ( self::LOG_RETENTION_DAYS * DAY_IN_SECONDS ) );
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $log_table WHERE logged_at < %s",
				$cutoff
			)
		);
	}

	/**
	 * Verify AJAX nonce and return JSON (not bare -1) on failure.
	 */
	private function verifyAjaxNonce(): void {
		$nonce = isset( $_REQUEST['nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'tl_nonce' ) ) {
			wp_send_json_error(
				[ 'message' => __( 'Security check failed. Refresh the page and try again.', 'wppowersuite-lite' ) ],
				403
			);
		}
	}

	/**
	 * AJAX: Create Login
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxCreateLogin(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

		$user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';

		// Validate: need either user_id or email
		if ( empty( $user_id ) && empty( $email ) ) {
			wp_send_json_error( [ 'message' => __( 'Please select a user or enter an email address.', 'wppowersuite-lite' ) ] );
		}

		if ( ! empty( $email ) && ! is_email( $email ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid email address.', 'wppowersuite-lite' ) ] );
		}

		$args = [
			'user_id'              => $user_id,
			'email'                => $email,
			'role'                 => isset( $_POST['role'] ) ? sanitize_text_field( $_POST['role'] ) : 'subscriber',
			'expiry_type'          => isset( $_POST['expiry_type'] ) ? sanitize_text_field( $_POST['expiry_type'] ) : 'day',
			'expiry_date'          => isset( $_POST['expiry_date'] ) ? sanitize_text_field( $_POST['expiry_date'] ) : null,
			'single_use'           => isset( $_POST['single_use'] ) && $_POST['single_use'] === '1',
			'max_uses'             => isset( $_POST['max_uses'] ) ? absint( $_POST['max_uses'] ) : 0,
			'ip_restriction'       => isset( $_POST['ip_restriction'] ) ? sanitize_text_field( $_POST['ip_restriction'] ) : '',
			'bind_user_agent'      => isset( $_POST['bind_user_agent'] ) && $_POST['bind_user_agent'] === '1',
			'redirect_to'          => isset( $_POST['redirect_to'] ) ? esc_url_raw( $_POST['redirect_to'] ) : '',
			self::PERM_INSTALL_PLUGINS => isset( $_POST[ self::PERM_INSTALL_PLUGINS ] ) && $_POST[ self::PERM_INSTALL_PLUGINS ] === '1',
			self::PERM_DELETE_PLUGINS  => isset( $_POST[ self::PERM_DELETE_PLUGINS ] ) && $_POST[ self::PERM_DELETE_PLUGINS ] === '1',
			self::PERM_INSTALL_THEMES  => isset( $_POST[ self::PERM_INSTALL_THEMES ] ) && $_POST[ self::PERM_INSTALL_THEMES ] === '1',
			self::PERM_DELETE_THEMES   => isset( $_POST[ self::PERM_DELETE_THEMES ] ) && $_POST[ self::PERM_DELETE_THEMES ] === '1',
		];

		$result = $this->createLogin( $args );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( [ 'message' => $result->get_error_message() ] );
		}

		$login_url = add_query_arg( self::TOKEN_PARAM, $result['token'], home_url() );

		wp_send_json_success(
			[
				'login_id'  => $result['id'],
				'login_url' => $login_url,
				'message'   => __( 'Temporary login created successfully.', 'wppowersuite-lite' ),
			]
		);
	}

	/**
	 * AJAX: Return the current login URL for Copy (does not rotate).
	 *
	 * @return void
	 */
	public function ajaxIssueCopyLink(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

		$login_id = isset( $_POST['login_id'] ) ? absint( $_POST['login_id'] ) : 0;
		if ( ! $login_id ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$plain = $this->issueCopyableToken( $login_id );
		if ( is_wp_error( $plain ) ) {
			wp_send_json_error( [ 'message' => $plain->get_error_message() ] );
		}

		wp_send_json_success(
			[
				'login_url' => $this->getLoginUrl( $plain ),
			]
		);
	}

	/**
	 * AJAX: Rotate the login token (invalidates the previous URL) and return the new one.
	 *
	 * @return void
	 */
	public function ajaxRegenerateLoginLink(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

		$login_id = isset( $_POST['login_id'] ) ? absint( $_POST['login_id'] ) : 0;
		if ( ! $login_id ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$plain = $this->regenerateLoginToken( $login_id, true );
		if ( is_wp_error( $plain ) ) {
			wp_send_json_error( [ 'message' => $plain->get_error_message() ] );
		}

		wp_send_json_success(
			[
				'login_url' => $this->getLoginUrl( $plain ),
			]
		);
	}

	/**
	 * AJAX: Search users for the create form.
	 *
	 * @return void
	 */
	public function ajaxSearchUsers(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

		$search = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
		if ( strlen( $search ) < 2 ) {
			wp_send_json_success( [ 'users' => [] ] );
		}

		$query = new \WP_User_Query(
			[
				'number'         => 20,
				'search'         => '*' . $search . '*',
				'search_columns' => [ 'user_login', 'user_nicename', 'user_email', 'display_name' ],
				'orderby'        => 'display_name',
				'order'          => 'ASC',
			]
		);

		$users = [];
		foreach ( $query->get_results() as $user ) {
			if ( ! $user instanceof \WP_User ) {
				continue;
			}
			$label = $user->display_name ?: $user->user_login;
			if ( $user->user_email ) {
				$label .= ' (' . $user->user_email . ')';
			}
			$users[] = [
				'id'    => (int) $user->ID,
				'label' => $label,
			];
		}

		wp_send_json_success( [ 'users' => $users ] );
	}

	/**
	 * AJAX: Revoke Login
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxRevokeLogin(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

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

		if ( empty( $login_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$result = $this->revokeLogin( $login_id );

		if ( $result ) {
			wp_send_json_success( [ 'message' => __( 'Login revoked successfully.', 'wppowersuite-lite' ) ] );
		} else {
			wp_send_json_error( [ 'message' => __( 'Failed to revoke login.', 'wppowersuite-lite' ) ] );
		}
	}

	/**
	 * AJAX: Enable Login
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxEnableLogin(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

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

		if ( empty( $login_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$result = $this->enableLogin( $login_id );

		if ( $result ) {
			wp_send_json_success( [ 'message' => __( 'Login enabled successfully.', 'wppowersuite-lite' ) ] );
		} else {
			wp_send_json_error( [ 'message' => __( 'Failed to enable login.', 'wppowersuite-lite' ) ] );
		}
	}

	/**
	 * AJAX: Delete Login
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxDeleteLogin(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

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

		if ( empty( $login_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$result = $this->deleteLogin( $login_id );

		if ( $result ) {
			wp_send_json_success( [ 'message' => __( 'Login deleted successfully.', 'wppowersuite-lite' ) ] );
		} else {
			wp_send_json_error( [ 'message' => __( 'Failed to delete login.', 'wppowersuite-lite' ) ] );
		}
	}

	/**
	 * AJAX: Get Login
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxGetLogin(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

		$login_id = isset( $_GET['login_id'] ) ? absint( $_GET['login_id'] ) : 0;

		if ( empty( $login_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$login = $this->getLoginById( $login_id );
		if ( ! $login ) {
			wp_send_json_error( [ 'message' => __( 'Login not found.', 'wppowersuite-lite' ) ] );
		}

		$user  = get_userdata( $login->user_id );
		$perms = $this->getLoginPermissions( $login );

		wp_send_json_success( [
			'login' => [
				'id'              => $login->id,
				'user_id'         => $login->user_id,
				'user_email'      => $user ? $user->user_email : '',
				'role'            => $login->role,
				'expiry_type'     => $this->getExpiryType( $login->expires_at ),
				'expiry_date'     => mysql2date( 'Y-m-d\TH:i', $login->expires_at, false ),
				'single_use'      => (bool) $login->single_use,
				'max_uses'        => $login->max_uses,
				'ip_restriction'  => $login->ip_restriction,
				'bind_user_agent' => ! empty( $login->bind_user_agent ),
				'redirect_to'     => $login->redirect_to,
				'install_plugins' => $perms[ self::PERM_INSTALL_PLUGINS ],
				'delete_plugins'  => $perms[ self::PERM_DELETE_PLUGINS ],
				'install_themes'  => $perms[ self::PERM_INSTALL_THEMES ],
				'delete_themes'   => $perms[ self::PERM_DELETE_THEMES ],
			],
		] );
	}

	/**
	 * AJAX: Update Login
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxUpdateLogin(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

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

		if ( empty( $login_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$args = [
			'role'            => isset( $_POST['role'] ) ? sanitize_text_field( $_POST['role'] ) : null,
			'expiry_type'     => isset( $_POST['expiry_type'] ) ? sanitize_text_field( $_POST['expiry_type'] ) : null,
			'expiry_date'     => isset( $_POST['expiry_date'] ) ? sanitize_text_field( $_POST['expiry_date'] ) : null,
			'single_use'      => isset( $_POST['single_use'] ) && $_POST['single_use'] === '1',
			'max_uses'        => isset( $_POST['max_uses'] ) ? absint( $_POST['max_uses'] ) : null,
			'ip_restriction'  => isset( $_POST['ip_restriction'] ) ? sanitize_text_field( $_POST['ip_restriction'] ) : null,
			'redirect_to'     => isset( $_POST['redirect_to'] ) ? esc_url_raw( $_POST['redirect_to'] ) : null,
			'bind_user_agent' => isset( $_POST['bind_user_agent'] ) && $_POST['bind_user_agent'] === '1',
			self::PERM_INSTALL_PLUGINS => isset( $_POST[ self::PERM_INSTALL_PLUGINS ] ) && $_POST[ self::PERM_INSTALL_PLUGINS ] === '1',
			self::PERM_DELETE_PLUGINS  => isset( $_POST[ self::PERM_DELETE_PLUGINS ] ) && $_POST[ self::PERM_DELETE_PLUGINS ] === '1',
			self::PERM_INSTALL_THEMES  => isset( $_POST[ self::PERM_INSTALL_THEMES ] ) && $_POST[ self::PERM_INSTALL_THEMES ] === '1',
			self::PERM_DELETE_THEMES   => isset( $_POST[ self::PERM_DELETE_THEMES ] ) && $_POST[ self::PERM_DELETE_THEMES ] === '1',
		];

		$args = array_filter(
			$args,
			static function ( $value ) {
				return $value !== null;
			}
		);

		$result = $this->updateLogin( $login_id, $args );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( [ 'message' => $result->get_error_message() ] );
		}

		if ( $result ) {
			wp_send_json_success( [ 'message' => __( 'Login updated successfully.', 'wppowersuite-lite' ) ] );
		} else {
			wp_send_json_error( [ 'message' => __( 'Failed to update login.', 'wppowersuite-lite' ) ] );
		}
	}

	/**
	 * AJAX: Get Logs
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxGetLogs(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wppowersuite-lite' ) ] );
		}

		$login_id = isset( $_GET['login_id'] ) ? absint( $_GET['login_id'] ) : 0;

		if ( empty( $login_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid login ID.', 'wppowersuite-lite' ) ] );
		}

		$logs = $this->getActivityLogs(
			[
				'login_id'       => $login_id,
				'limit'          => 100,
				'exclude_noise'  => true,
			]
		);

		wp_send_json_success( [ 'logs' => $logs ] );
	}

	/**
	 * Get Expiry Type
	 *
	 * Determines expiry type from expiry date.
	 *
	 * @since 1.0.0
	 * @param string $expires_at Expiry datetime.
	 * @return string Expiry type.
	 */
	private function getExpiryType( string $expires_at ): string {
		$diff = self::expiresAtToTimestamp( $expires_at ) - current_time( 'timestamp' );

		if ( $diff <= HOUR_IN_SECONDS ) {
			return 'hour';
		} elseif ( $diff <= HOUR_IN_SECONDS * 3 ) {
			return '3_hours';
		} elseif ( $diff <= DAY_IN_SECONDS ) {
			return 'day';
		} elseif ( $diff <= DAY_IN_SECONDS * 3 ) {
			return '3_days';
		} elseif ( $diff <= WEEK_IN_SECONDS ) {
			return 'week';
		} elseif ( $diff <= MONTH_IN_SECONDS ) {
			return 'month';
		} else {
			return 'custom';
		}
	}

	/**
	 * AJAX: Export Logs
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function ajaxExportLogs(): void {
		$this->verifyAjaxNonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'wppowersuite-lite' ) );
		}

		$login_id = isset( $_GET['login_id'] ) ? absint( $_GET['login_id'] ) : 0;
		$logs = $this->getActivityLogs(
			[
				'login_id'      => $login_id ?: null,
				'exclude_noise' => true,
			]
		);

		header( 'Content-Type: text/csv' );
		header( 'Content-Disposition: attachment; filename="temporary-login-logs-' . gmdate( 'Y-m-d' ) . '.csv"' );

		$output = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV stream to the browser.
		fputcsv( $output, [ 'ID', 'Login ID', 'User ID', 'Action', 'IP Address', 'User Agent', 'Page Accessed', 'Logged At' ] );

		foreach ( $logs as $log ) {
			fputcsv(
				$output,
				[
					$log->id,
					$log->login_id,
					$log->user_id,
					$log->action,
					$log->ip_address,
					$log->user_agent,
					$log->page_accessed,
					$log->logged_at,
				]
			);
		}

		fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- CSV stream to the browser.
		exit;
	}

	/**
	 * Get Login URL
	 *
	 * Gets the login URL for a token.
	 *
	 * @since 1.0.0
	 * @param string $token Token.
	 * @return string Login URL.
	 */
	public function getLoginUrl( string $plain_token ): string {
		return add_query_arg( self::TOKEN_PARAM, $plain_token, home_url() );
	}

}
