```
namespace app\components\services;

use \Throwable;

use Yii;
use yii\db\Exception as YiiDbException;

use app\components\SessionManager;
use app\components\interfaces\SessionInterface;
use app\models\SessionUser;
use app\helpers\ExceptionLogger;
use app\helpers\LogHelper;
use app\helpers\RoleHelper;

class SessionUserService
{
	private bool $_debugMode = true;
	
	/**
	* Session Manager Interface
	* @see app\components\interfaces\SessionInterface.php
	* @var SessionInterface
	*/
	private SessionInterface $_sessionManager;
	
	/**
	* Cache the SessionUser model during request lifecycle.
	* @var SessionUser|null
	*/
	private ?SessionUser $_cachedSessionUser = null;
	
	// @var Cache tokens per session ID
	private array $_cachedSessionTokens = [];
	
	public function __construct(?SessionInterface $sessionManager = null)
	{
		$this->_sessionManager = $sessionManager ?? new SessionManager();
	}
	
	/**
	* Has Session
	* @return bool
	*
	* Checks whether there is valid session data in the Yii session store
	* for the current user's session token.
	*/
	public function hasSession(): bool
	{
		$sessionId = $this->_sessionManager->getId();
		if (empty($sessionId))
		{
			return false;
		}
		
		$sessionToken = $this->getSessionToken($sessionId);
		if ($sessionToken === null)
		{
			return false;
		}
		
		$key = SessionUser::SESSION_USER_KEY_PREFIX . substr($sessionToken, 0, 16);
		
		return $this->_sessionManager->has($key);
	}
	
	/**
	* Write or update the session data.
	* @param array $data Associative array with optional 'extra_data' keys.
	* @param bool $allowCriticalFieldUpdate Whether to allow updating token, IP, agent fields on existing record.
	* @return bool True on success, false on failure.
	* Validates data, generates tokens if missing, sets session data both in Yii session storage (via SessionInterface)
	* and persists in DB.
	*
	* Example usage:
	*
	* 	$sessionUser = new SessionUser();
	* 	$success = $sessionUser->writeSession([
	* 		'extra_data' => ['order_id' => 'ABC123'],
	* 	]);
	*/
	public function writeSession(array $data = [], bool $allowCriticalFieldUpdate = false): bool
	{
		//$this->_sessionManager->destroy();// Debugging
		
		// If $data array is empty, theirs nothing to write, return false.
		if (empty($data))
		{
			LogHelper::debug('Attempted to write empty session data.');
			
			return false;
		}
		
		// Validate 'extra_data' if it exists with validateJsonData().
		if (isset($data['extra_data']) && !$this->validateJsonData($data['extra_data']))
		{
			if ($this->_debugMode)
			{
				LogHelper::error('Invalid extra_data JSON structure.', [
					'extra data' => json_encode($data['extra_data']),
					'allow critical field update' => $allowCriticalFieldUpdate
				]);
			}
			
			return false;
		}
		
		// Validate 'cart_data' if it exists with validateCartData().
		if (isset($data['cart_data']) && !$this->validateCartData($data['cart_data']))
		{
			if ($this->_debugMode)
			{
				LogHelper::error('Invalid cart_data JSON structure.', [
					'cart data' => json_encode($data['cart_data']),
					'allow critical field update' => $allowCriticalFieldUpdate
				]);
			}
			
			return false;
		}
		
		// Retrieve the session ID and session token.
		$sessionId = $this->_sessionManager->getId();
		
		// Fetch existing token or generate a new one
		$sessionToken = $this->getSessionToken($sessionId);
		if ($sessionToken === null)
		{
			$sessionToken = $this->generateSessionToken();
			$this->_cachedSessionTokens[$sessionId] = $sessionToken;
		}
		
		$sessionTokenKey = substr($sessionToken, 0, 16);
		
		try
		{
			// Prepare data to be stored in Yii session and DB
			$sessionData = $this->initializeSessionData($sessionId, $sessionToken, $data);
			
			// Store session data in Yii session storage using canonical key
			$this->_sessionManager->set(SessionUser::SESSION_USER_KEY_PREFIX . $sessionTokenKey, $sessionData);
			
			// Save or update session data in DB
			return $this->saveSession($sessionId, $sessionData, $allowCriticalFieldUpdate);
		}
		catch (Throwable $e)
		{
			// Use the ExceptionLogger to handle and log the exception.
			ExceptionLogger::log($e, 'sessionUser_writeSession_error_');
			
			return false;
		}
	}
	
	/**
	* Reads and validates the session data.
	* @return array|null Session data or null.
	*
	* Returns the session data array if valid (token matches DB, IP and UA checks, not expired),
	* or null if session is invalid or missing.
	*
	* Usage example:
	*
	* 	$sessionUser = new SessionUser();
	* 	$sessionData = $sessionUser->readSession();
	* 	if ($sessionData === null)
	*	{
	* 		// Handle invalid session, e.g., redirect to login
	* 	}
	*/
	public function readSession(): ?array
	{
		//$this->_sessionManager->destroy();// Debugging
		
		$sessionId = $this->_sessionManager->getId();
		
		if ($this->_debugMode)
		{
			LogHelper::debug('SessionUser::readSession called with sessionId', [
				'session ID' => $sessionId
			]);
		}
		
		if (empty($sessionId))
		{
			return null;
		}
		
		$sessionToken = $this->getSessionToken($sessionId);
		if ($sessionToken === null)
		{
			return null;
		}
		
		// Subtract x amount from $sessionToken for validateSessionData() mechanism.
		$sessionTokenKey = substr($sessionToken, 0, 16);
		
		// Read session data from session storage (Yii)
		$sessionData = $this->fetchSessionData($sessionTokenKey);
		if ($sessionData === null)
		{
			return null;
		}
		
		// Get session record in DB
		$sessionUser = $this->getSession($sessionId);
			
		// Validate token match, IP/user-agent, expiration...
		if ($sessionUser !== null && !$sessionUser->isExpired() && 
			$this->validateSessionData($sessionData, $sessionUser) &&
			$this->validateRequest($sessionUser)
		)
		{
			return $sessionData;
		}
			
		LogHelper::warning("Session validation failed for session_id: {$sessionId}", [
			'session ID' => $sessionId
		]);
			
		return null;
	}
	
	/**
	* Fetch Session Data
	* @param string $sessionTokenKey Prefix substring of token.
	* @return array|null Session data or null.
	*
	* Fetches session data stored in Yii session by token key.
	*/
	private function fetchSessionData(string $sessionTokenKey): ?array
	{
		$key = SessionUser::SESSION_USER_KEY_PREFIX . $sessionTokenKey;
		try
		{
			if (!$this->_sessionManager->has($key))
			{
				if ($this->_debugMode)
				{
					LogHelper::debug("Session key not found in Yii session storage.", ['key' => $key]);
				}
				
				return null;
			}
			
			$data = $this->_sessionManager->get($key);
			
			if ($this->_debugMode)
			{
				LogHelper::debug("Session data fetched from Yii session storage for key.", [
					'key' => $key,
					'data' => json_encode($data)
				]);
			}
			
			return $data;
		}
		catch (\Throwable $e)
		{
			// Use the ExceptionLogger to handle and log the exception.
			ExceptionLogger::log($e, 'sessionUser_fetchSessionData_error_');
		}
		
		return null;
	}
	
	/**
	* Save Session
	* @param string $sessionId
	* @param array $sessionData
	* @param bool $allowCriticalFieldUpdate
	* @return bool True on success.
	*
	* Saves or updates the session user record in the DB.
	*/
	private function saveSession(
		string $sessionId,
		array $sessionData,
		bool $allowCriticalFieldUpdate = false
	): bool
	{
		try
		{
			$sessionUser = $this->getSession($sessionId);
			
			if ($sessionUser === null)
			{
				// Create new SessionUser record if missing
				$sessionUser = new SessionUser();
				$sessionUser->session_id = $sessionId;
				$sessionUser->session_token = $sessionData['session_token'];
				$sessionUser->user_ip = $sessionData['user_ip'];
				$sessionUser->user_agent = $sessionData['user_agent'];
				$sessionUser->extra_data = json_encode($sessionData['extra_data'] ?? [], JSON_THROW_ON_ERROR);
				$sessionUser->cart_data = json_encode($sessionData['cart_data'] ?? [], JSON_THROW_ON_ERROR);
				$sessionUser->expires_at = $sessionData['expires_at'] ?? null;
				
				if (!$sessionUser->save(false))
				{
					throw new YiiDbException('Failed to create new SessionUser DB record.');
				}
			}
			else
			{
				$updated = false;
				
				if ($allowCriticalFieldUpdate && $sessionUser->session_id !== null)
				{
					foreach (['session_token', 'user_ip', 'user_agent'] as $field)
					{
						if (isset($sessionData[$field]) && $sessionUser->{$field} !== $sessionData[$field])
						{
							$sessionUser->{$field} = $sessionData[$field];
							$updated = true;
						}
					}
				}
				
				$sessionUser->session_token = $sessionData['session_token'];
				$sessionUser->user_ip = $sessionData['user_ip'];
				$sessionUser->user_agent = $sessionData['user_agent'];
				$sessionUser->extra_data = json_encode($sessionData['extra_data'] ?? [], JSON_THROW_ON_ERROR);
				$sessionUser->cart_data = json_encode($sessionData['cart_data'] ?? [], JSON_THROW_ON_ERROR);
				$sessionUser->expires_at = $sessionData['expires_at'] ?? null;
				
				if ($updated || $sessionUser->isNewRecord)
				{
					if (!$sessionUser->save(false))
					{
						throw new YiiDbException('Failed to update user session in the database.');
					}
				}
			}
			
			// Update caches
			$this->_cachedSessionUser = $sessionUser;
			$this->_cachedSessionTokens[$sessionId] = $sessionUser->session_token;
			
			return true;
		}
		catch (Throwable $e)
		{
			ExceptionLogger::log($e, 'sessionUser_saveSession_error_');
		}
		
		return false;
	}
	
	
	
	/**
	* Initialize Session Data
	* @param string $sessionId Session ID from Yii session component.
	* @param string $sessionToken Token string.
	* @param array $data Additional session data ('extra_data').
	* @return array Prepared associative array.
	*
	* Prepares the session data to store within Yii session and DB.
	*/
	private function initializeSessionData(string $sessionId, string $sessionToken, array $data = []): array
	{
		return [
			//'session_id' => $sessionId,// TODO Hide 'session_id' on production
			'session_token' => $sessionToken,
			'user_ip' => RoleHelper::userIp(),
			'user_agent' => RoleHelper::userAgent(),
			'extra_data' => array_merge(isset($data['extra_data']) ? $data['extra_data'] : [], [
				// Add any new default values you want to merge into extra_data here.
				// Example: 'additional_key' => 'additional_value',
			]),
			'cart_data' => isset($data['cart_data']) ? $data['cart_data'] : [],// Accessing directly from the input.
			'expires_at' => time() + SessionUser::SESSION_DURATION_SECONDS,
		];
	}
	
	/**
	* Validate Session Data
	* @param array $sessionData Session data from Yii session storage.
	* @param SessionUser $sessionUser The DB session record.
	* @return bool True if tokens match.
	*
	* Compares session token stored in DB with the one stored in the Yii session.
	*/
	private function validateSessionData(array $sessionData, SessionUser $sessionUser): bool
	{
		return ($sessionData['session_token'] ?? null) === $sessionUser->session_token;
	}
	
	/**
	* Validate Request
	* @return bool
	*
	* Checks whether current request IP and User-Agent match stored values.
	* Returns `true` if IP and User-Agent match (or match subnet per ipMatches), else false.
	*/
	public function validateRequest(SessionUser $sessionUser): bool
	{
		$currentIp = RoleHelper::userIp();
		$currentAgent = RoleHelper::userAgent();
	
		if ($this->_debugMode)
		{
			LogHelper::debug("Stored IP: {$sessionUser->user_ip}, Current IP: {$currentIp}", [
				'user IP' => $sessionUser->user_ip,
				'current IP' => $currentIp
			]);
			
			LogHelper::debug("Stored UA: {$sessionUser->user_agent}, Current UA: {$currentAgent}", [
				'user agent' => $sessionUser->user_agent,
				'current agent' => $currentAgent
			]);
		}
		
		if (!$this->ipMatches($sessionUser->user_ip, $currentIp))
		{
			if ($this->_debugMode)
			{
				LogHelper::warning("IP mismatch for session {$sessionUser->session_id}." , [
					'session' => $sessionUser->session_id,
					'user IP' => $sessionUser->user_ip,
					'current IP' => $currentIp
				]);
			}
			
			return false;
		}
		
		$storedAgent = $sessionUser->user_agent ?? '';
		
		// Simplified example: compare only first 30 character
		if (strncmp($storedAgent, $currentAgent, 30) !== 0)
		//if ($this->user_agent !== $currentAgent)
		{
			if ($this->_debugMode)
			{
				LogHelper::warning("User-Agent mismatch for session {$sessionUser->session_id}." , [
					'session' => $sessionUser->session_id,
					'user agent' => $sessionUser->user_agent,
					'current agent' => $currentAgent
				]);
			}
			
			return false;
		}
		
		return true;
	}
	
	/**
	* IP Matches
	* @param string $storedIp IP stored in DB.
	* @param string $currentIp Current IP from request.
	* @return bool Match result.
	*
	* Compares stored IP with current IP allowing /24 subnet matching for IPv4.
	*/
	private function ipMatches(string $storedIp, string $currentIp): bool
	{
		// IPv4 subnet matching example
		if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) 
			&& filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
		)
		{
			return substr($storedIp, 0, strrpos($storedIp, '.')) === substr($currentIp, 0, strrpos($currentIp, '.'));
		}
		
		// Strict equality fallback for IPv6 or others
		return $storedIp === $currentIp;
	}
	
	/**
	* Validate Json Data
	* @param array $jsonData
	* @return bool Whether data is valid.
	*
	* Validates the structure of extra_data to conform with expected keys and value types.
	*/
	private function validateJsonData(array $jsonData): bool
	{
		if ($this->_debugMode)
		{
			LogHelper::debug('validateJsonData called with keys.', [
				'json data' => implode(', ', array_keys($jsonData))
			]);
		}
		
		// TODO Define allowed keys for 'extra_data'.
		$allowedKeys = ['last_login_time', 'order_id', 'order_success_url', 'order_canceled_url'];
		
		// Restrict the number of entries in 'extra_data'.
		if (count($jsonData) > 10)
		{
			if ($this->_debugMode)
			{
				LogHelper::debug('extra_data exceeds maximum number of keys.', [
					'number of keys' => count($jsonData)
				]);
			}
			
			return false;
		}
		
		foreach ($jsonData as $key => $value)
		{
			if ($this->_debugMode)
			{
				\Yii::debug("validating key: {$key} (hex: " . bin2hex($key) . ")", ['key' => $key]);
			}
			
			// Check if the key is valid.
			if (!is_string($key) || !in_array($key, $allowedKeys, true))
			{
				LogHelper::debug('Invalid key in extra_data.', ['key' => $key]);
				
				return false;
			}
			
			// Validate value type.
			if (!is_string($value) && !is_int($value))
			{
				if ($this->_debugMode)
				{
					LogHelper::debug('Invalid value type for key.', ['key' => $key]);
				}
				
				return false;
			}
			
			// Validate string length.
			if (is_string($value) && mb_strlen($value) > 255)
			{
				if ($this->_debugMode)
				{
					LogHelper::debug("Value for key {$key} exceeds max length: {$value}", [
						'key' => $key,
						'value' => $value
					]);
				}
				
				return false;
			}
		}
		
		return true;
	}
	
	/**
	* Validates cart data structure.
	* @param array $cartData The cart data to validate.
	* @return bool True if valid, false otherwise.
	*
	* Ensures that the cart data:
	* - Is an array
	* - Has no more than the allowed maximum number of items
	* - Each item is an associative array with required keys: 'id' and 'quantity'
	* - 'id' can be string or int, 'quantity' must be positive integer
	*/
	private function validateCartData(array $cartData): bool
	{
		// Configuration: max allowed items in cart
		$maxItems = 10;
		
		if (count($cartData) > $maxItems)
		{
			if ($this->_debugMode)
			{
				LogHelper::debug('Cart data exceeds maximum allowed items.', [
					'count' => count($cartData),
					'max' => $maxItems
				]);
			}
			
			return false;
		}
		
		foreach ($cartData as $index => $item)
		{
			if (!is_array($item) || !isset($item['id'], $item['quantity']))
			{
				if ($this->_debugMode)
				{
					LogHelper::debug("Cart item at index {$index} missing required keys or invalid.", [
						'item' => $item
					]);
				}
				
				return false;
			}
			if (!is_int($item['id']) && !is_string($item['id']))
			{
				if ($this->_debugMode)
				{
					LogHelper::debug("Cart item 'id' at index {$index} must be int or string.", [
						'id' => $item['id']
					]);
				}
				
				return false;
			}
			
			$qty = $item['quantity'];
			
			if (!((is_int($qty) && $qty > 0) || (is_string($qty) && ctype_digit($qty) && (int) $qty > 0))
			)
			{
				if ($this->_debugMode)
				{
					LogHelper::debug("Cart item 'quantity' at index {$index} must be a positive integer.", [
						'quantity' => $qty
					]);
				}
				
				return false;
			}
		}
		
		return true;
	}
	
	/**
	* Invalidate
	* @return bool True on success.
	*
	* Deletes this session record from DB — forcing logout.
	*/
	public function invalidate(string $sessionId): bool
	{
		$sessionUser = SessionUser::findOne(['session_id' => $sessionId]);
		
		if ($sessionUser === null)
		{
			LogHelper::warning("Attempted to invalidate non-existing session user with ID {$sessionId}", [
				'ID' => $sessionId
			]);
			
			return false;
		}
		
		$result = $sessionUser->delete() !== false;
		
		if ($result)
		{
			$this->_cachedSessionUser = null;
			
			unset($this->_cachedSessionTokens[$sessionId]);
		}
		
		if ($this->_debugMode)
		{
			LogHelper::debug("Invalidate session with ID {$sessionId}", ['session ID' => $sessionId]);
		}
		
		return $result;
	}
	
	/**
	* Get Session
	* @param string $sessionId
	* @return SessionUser|null
	*
	* Get SessionUser model by session ID with in-memory caching.
	*/
	private function getSession(string $sessionId): ?SessionUser
	{
		// Return cached if available and matches requested sessionId
		if ($this->_cachedSessionUser !== null && $this->_cachedSessionUser->session_id === $sessionId)
		{
			if ($this->_debugMode)
			{
				LogHelper::debug('Using cached SessionUser model for sessionId.', ['session ID' => $sessionId]);
			}
			
			return $this->_cachedSessionUser;
		}
		
		if ($this->_debugMode)
		{
			LogHelper::debug('Querying SessionUser DB record for sessionId.', ['session ID' => $sessionId]);
		}
		
		// Get from SessionUser ActiveRecord
		$user = SessionUser::findOne(['session_id' => $sessionId]);
		
		if ($user === null)
		{
			LogHelper::warning('No SessionUser DB record found for sessionId.', ['session ID' => $sessionId]);
		}
		else
		{
			if ($this->_debugMode)
			{
				LogHelper::debug('SessionUser DB record found for sessionId.', ['session ID' => $sessionId]);
			}
		}
		
		$this->_cachedSessionUser = $sessionUser;
		
		return $sessionUser;
	}
	
	/**
	* Generate Session Token
	* @return string A randomly generated session token.
	*
	* Generates a secure random session token.
	*/
	private function generateSessionToken(): string
	{
		return \Yii::$app->security->generateRandomString(64);
	}
	
	/**
	* Get Session Token
	* @param string $sessionId
	* @return string|null Returns the session token or null.
	*
	* Gets the session token associated with the DB record for a session ID.
	*/
	private function getSessionToken(string $sessionId): ?string
	{
		if (isset($this->_cachedSessionTokens[$sessionId]))
		{
			return $this->_cachedSessionTokens[$sessionId];
		}
		
		$sessionUser = $this->getSession($sessionId);
		
		if ($sessionUser !== null && !empty($sessionUser->session_token))
		{
			$this->_cachedSessionTokens[$sessionId] = $sessionUser->session_token;
			
			return $sessionUser->session_token;
		}
		
		return null;
	}
	
	/**
	* Roll Session Token
	* @return bool True on success.
	*
	* This method fetches the session data from the Yii session storage,
	* generates a new token, updates storage with new token, removes the old,
	* and saves the new token in DB.
	*
	* Usage example:
	*
	*	$sessionId = Yii::$app->sessionManager->getId();
	*	$success = Yii::$app->serviceSessionUser->rollSessionToken($sessionId);
	* 
	* 	if ($sessionUser->rollSessionToken())
	* 	{
	* 		// Token refreshed successfully
	* 	}
	*/
	public function rollSessionToken(string $sessionId): bool
	{
		$sessionUser = $this->getSession($sessionId);
		
		if ($sessionUser === null)
		{
			LogHelper::warning("No SessionUser record found in rollSessionToken for id: {$sessionId}", [
				'session ID' => $sessionId
			]);
			
			return false;
		}
		
		$oldTokenKey = substr($this->session_token, 0, 16);
		$newToken = $this->generateSessionToken();
		$newTokenKey = substr($newToken, 0, 16);
		
		$oldSessionData = $this->_sessionManager->get(self::SESSION_USER_KEY_PREFIX . $oldTokenKey);
		if ($oldSessionData === null)
		{
			LogHelper::warning("No existing session data found during token roll.");
			
			return false;
		}
		
		$oldSessionData['session_token'] = $newToken;
		
		// Remove old session key and save new
		$this->_sessionManager->remove(self::SESSION_USER_KEY_PREFIX . $oldTokenKey);
		$this->_sessionManager->set(self::SESSION_USER_KEY_PREFIX . $newTokenKey, $oldSessionData);
		
		try
		{
			$sessionUser->session_token = $newToken;
			if (!$sessionUser->save(false, ['session_token']))
			{
				throw new YiiDbException("Failed to save session token to the database.");
			}
			
			return true;
		}
		catch (Throwable $e)
		{
			// Use the ExceptionLogger to handle and log the exception.
			ExceptionLogger::log($e, 'session_user_service_rollSessionToken_error_');
		}
		
		return false;
	}
}
```

thinking of ways of making this more robust, should I change:

private ?SessionUser $_cachedSessionUser = null;

to

private static array $_cachedSessionUsers = [];

and for example in getSession() i use "$this->_cachedSessionUser = $sessionUser;" and switch this to:

self::$_cachedSessionUsers[$sessionId] = $user;