<?php
/**
 * @package   admintools
 * @copyright Copyright (c)2010-2026 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace Akeeba\Plugin\System\AdminTools\Feature;

defined('_JEXEC') || die;

use Akeeba\Plugin\System\AdminTools\Feature\Mixin\SuperUsersTrait;
use DateInterval;
use Exception;
use Joomla\CMS\Factory;
use Joomla\CMS\User\User;

/**
 * Disable or force a password reset on obsolete administrators (backend users who have not logged into the site for a
 * very long time)
 *
 * WAF configuration parameters:
 * disableobsoleteadmins            Is this feature enabled? Default: 0.
 * disableobsoleteadmins_freq       How often to run this feature [minutes]. Default: 60.
 * disableobsoleteadmins_groups     Which user groups to apply to? Default: empty (all groups)
 * disableobsoleteadmins_maxdays    Minimum time since last login to trigger this feature [days]. Default: 90
 * disableobsoleteadmins_action     Action to take (block|reset). Default: reset
 * disableobsoleteadmins_protected  Protected users
 *
 * @since       5.3.0
 */
class DisableObsoleteAdmins extends Base
{
	use SuperUsersTrait;

	/**
	 * WAF settings key prefix for this feature
	 *
	 * @var   string
	 * @since 5.3.0
	 */
	protected $settingsKey = 'disableobsoleteadmins';

	/**
	 * If the user has not logged in for at least this many days we are going to block / force reset their password.
	 *
	 * @var   int
	 * @since 5.3.0
	 */
	protected $maxDays = 0;

	/**
	 * When saving a user who was previously blocked, undoing the block, I have to update their last visit date to today
	 * so they don't get auto-blocked again. Joomla! does not let me modify the data before a user is saved to the
	 * database. What I do instead is intercept the onBeforeSave events, create a list of the users who need to be
	 * modified and then apply these changes by capturing the onUserAfterSave event for this user. The problem is that
	 * by doing so I am triggering yet again the onUserBeforeSave which would make me enter an infinite loop. This array
	 * lets me keep track of the user IDs I am fiddling with so I don't end up in an infinite loop. It's an array
	 * because due to the way user plugins work I *might* end up in a recursive update situation.
	 *
	 * @var   array
	 * @since 5.3.0
	 */
	protected $toUpdateUsers = [];

	/**
	 * This is part of the solution described in the $toUpdateUsers above. This array keeps track of the User IDs I have
	 * already started processing onUserAfterSave so I don't process them again.
	 *
	 * @var   array
	 * @since 5.3.0
	 */
	protected $updatedUsers = [];

	/**
	 * Is this feature enabled?
	 *
	 * @return  bool
	 *
	 * @since   5.3.0
	 */
	public function isEnabled()
	{
		if ($this->wafParams->getValue($this->settingsKey, 0) != 1)
		{
			return false;
		}

		$this->maxDays = $this->wafParams->getValue($this->settingsKey . '_maxdays', 90);

		if ($this->maxDays <= 0)
		{
			return false;
		}

		return true;
	}

	/**
	 * Runs as soon as the application has finished initializing, before it routes to a component. We will run our
	 * feature at most every disableobsoleteadmins_freq minutes (default: every 60 minutes)
	 *
	 * @throws  Exception
	 *
	 * @since   5.3.0
	 */
	public function onAfterInitialise(): void
	{
		$minutes = $this->getRunFrequency();

		$lastJob = $this->getTimestamp($this->settingsKey);
		$nextJob = $lastJob + $minutes * 60;

		$now = clone Factory::getDate();

		if ($now->toUnix() >= $nextJob)
		{
			$this->setTimestamp($this->settingsKey);
			$this->disableObsoleteAdmins();
		}
	}

	/**
	 * Prevent automatic blocking of a backend user manually unblocked by an admin user.
	 *
	 * Presumably one of your users got blocked and they asked you to manually reset their password because they can't
	 * figure out the password reset instructions. If you edit them and remove the forced password reset / user block
	 * from their user account they will be automatically blocked again by this feature. This happens because their
	 * last visit date is before the configured max days threshold since they haven't actually logged in yet! We need to
	 * catch that case and update their last visit day to today to prevent blocking them all over again.
	 *
	 * However, Joomla! only allows us to see data onUserBeforeSave, not update them. Therefore I am using the
	 * onUserBeforeSave event to find out which user accounts are being saved and which need fiddling with per above.
	 * Then I used onUserAfterSave to update their lastVisitDate.
	 *
	 * @param   User|array  $oldUser  The existing user record
	 * @param   bool        $isNew    Is this a new user?
	 * @param   array       $data     The data to be saved
	 *
	 * @throws  Exception  When we catch a security exception
	 */
	public function onUserBeforeSave($oldUser, $isNew, $data): bool
	{
		// I only care about editing users from the backend
		if ($this->app->isClient('site'))
		{
			return true;
		}

		// I only care about editing existing users
		if ($isNew)
		{
			return true;
		}

		// I don't care if you are editing yourself
		if ($oldUser['id'] == $this->app->getIdentity()->id)
		{
			return true;
		}

		// Do not process the user I am already updating after save.
		if (in_array($oldUser['id'], $this->toUpdateUsers))
		{
			return true;
		}

		// Do not process the user I have already updated after save.
		if (in_array($oldUser['id'], $this->updatedUsers))
		{
			return true;
		}

		$action = $this->wafParams->getValue($this->settingsKey . '_action', 'reset') == 'block' ? 'block' : 'reset';

		switch ($action)
		{
			case 'block':
				// If the user wasn't blocked I have nothing to do
				if ($oldUser['block'] == 0)
				{
					return true;
				}

				// If you didn't change the user block status I have nothing to do
				if ($data['block'] == 1)
				{
					return true;
				}
				break;

			case 'reset':
			default:
				// If the user wasn't required to password reset I have nothing to do
				if ($oldUser['requireReset'] == 0)
				{
					return true;
				}

				// If you didn't change the user's required password reset status I have nothing to do
				if ($data['requireReset'] == 1)
				{
					return true;
				}
				break;
		}

		// You are possibly editing a user I previously disabled automatically. Is this REALLY the case?
		if (!empty($oldUser['lastvisitDate']) && ($oldUser['lastvisitDate'] != $this->db->getNullDate()))
		{
			$now       = clone Factory::getDate();
			$lastLogin = clone Factory::getDate($oldUser['lastvisitDate']);
			$diff      = $now->diff($lastLogin, true);

			// If the last login was within the allowed number of days you are editing a user I must NOT touch.
			if ($diff->days <= $this->maxDays)
			{
				return true;
			}
		}

		// Mark this user as in need for post-save update
		$this->toUpdateUsers[] = $oldUser['id'];

		return true;
	}

	/**
	 * Part of the automatic update of manually unblocked users, as explained onUserBeforeSave.
	 *
	 * @param   array   $data          The user data saved to the database
	 * @param   bool    $isNew         Was that a new user?
	 * @param   bool    $result        Did the save succeed?
	 * @param   string  $errorMessage  The last error message while saving the user.
	 *
	 *
	 * @since   5.3.0
	 */
	public function onUserAfterSave($data, $isNew, $result, $errorMessage): void
	{
		// I don't care about new users
		if ($isNew)
		{
			return;
		}

		// I don't care about failed saves
		if (!$result)
		{
			return;
		}

		// Get the user ID
		$userID = $data['id'];

		// Do not process the user I have already updated after save.
		if (in_array($userID, $this->updatedUsers))
		{
			return;
		}

		// Do not process a user UNLESS I have marked them as in need for an update.
		if (!in_array($userID, $this->toUpdateUsers))
		{
			return;
		}

		// Mark the user as having their last visit date updated
		$this->updatedUsers[] = $userID;

		// Update the last visit date to today
		$user                = self::getUserById($userID);
		$user->lastvisitDate = (clone Factory::getDate())->toSql();
		$user->save(true);
	}

	/**
	 * Find users who belong in the configured backend user groups and who have not logged in for at least the
	 * configured number of days. Then take the configured action against them (force password reset or block them).
	 *
	 * @throws  Exception
	 * @since   5.3.0
	 */
	protected function disableObsoleteAdmins()
	{
		// Get applicable user groups
		$groups = $this->getBackendUserGroups();

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

		// Get all applicable users
		$users = $this->getUsersByGroups($groups);

		// No users? Nothing to do, then.
		if (empty($users))
		{
			return;
		}

		// Remove "protected" users from this list
		$users = array_unique($users);
		$users = $this->removeProtectedUsers($users);

		// No users left after this operation? Nothing to do, then.
		if (empty($users))
		{
			return;
		}

		asort($users);

		// Get the login date to trigger this feature
		$now      = clone Factory::getDate();
		$interval = new DateInterval(sprintf('P%dD', $this->maxDays));
		$then     = $now->sub($interval)->toSql();

		// Have any of these users not logged in for a while?
		try
		{
			$db    = $this->db;
			$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
				->select([$db->qn('id')])
				->from($db->qn('#__users'))
				->where($db->qn('id') . ' IN (' . implode(', ', array_map([$db, 'q'], $users)) . ')')
				->where($db->qn('block') . ' = ' . $db->q(0))
				->where($db->qn('requireReset') . ' = ' . $db->q(0))
				->where($db->qn('lastvisitDate') . ' <= ' . $db->q($then));

			$actionUsers = $db->setQuery($query)->loadColumn(0);
		}
		catch (Exception $e)
		{
			// Database error. Bail out.
		}

		asort($actionUsers);

		// Get the applicable action
		$action = $this->wafParams->getValue($this->settingsKey . '_action', 'reset') == 'block' ? 'block' : 'reset';

		/**
		 * Am I trying to block all Super Users AND I am not protecting any Super Users THEN I will not block any Super
		 * Users at all.
		 *
		 * Why not do the same if I am forcing a password reset? Because in this case all Super Users can reset their
		 * password over email. No harm done. You don't get locked out of your site.
		 *
		 * Why do this even when I have protected users? Because the user may have chosen to protect non-Super-Users by
		 * accident / because they do not understand the consequences. In this case I have to make sure I am not
		 * blocking any Super Users on their site or they risk getting locked out of it permanently.
		 */
		if ($action == 'block')
		{
			$actionUsers = $this->filterActionableUsersToEnsureRemainingSuperUser($actionUsers);
		}

		// No users to take action against?.
		if (empty($actionUsers))
		{
			return;
		}

		// Apply the action
		$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
			->update($db->qn('#__users'))
			->where($db->qn('id') . ' IN (' . implode(', ', array_map([$db, 'q'], $actionUsers)) . ')');

		switch ($action)
		{
			case 'block':
				$query->set($db->qn('block') . ' = ' . $db->q(1));
				break;

			case 'reset':
			default:
				$query->set($db->qn('requireReset') . ' = ' . $db->q(1));
				break;
		}

		$db->setQuery($query)->execute();
	}

	/**
	 * Get the user groups configured by the user, filtered by those which really have backend access. If no groups are
	 * configured we will use all groups with backend access.
	 *
	 * @since   5.3.0
	 */
	protected function getBackendUserGroups()
	{
		// Get the configured groups
		$groups = $this->wafParams->getValue($this->settingsKey . '_groups', []);
		$groups = is_string($groups) ? explode(',', trim($groups)) : $groups;
		$groups = array_filter($groups, function ($group) {
			return (int) trim($group) != 0;
		});

		// No groups? Assume we're to look into all Joomla! user groups.
		if (empty($groups))
		{
			$groups = $this->getAllJoomlaUserGroups();
			// Filter the configured user groups by those with backend access
			$groups = array_filter($groups, [$this, 'isBackendAccessGroup']);
		}

		return $groups;

	}

	/**
	 * Remove the protected users from the given $users list and return the remaining users
	 *
	 * @param   array  $users  The users list to filter
	 *
	 * @return  array  The filtered list
	 *
	 * @since   5.3.0
	 */
	protected function removeProtectedUsers(array $users)
	{
		$protected = $this->getProtectedUsers();

		if (empty($protected))
		{
			return $users;
		}

		return array_diff($users, $protected);
	}

	/**
	 * Filter the list of actionable users in a way that ensures at least one Super User will remain active on the site
	 *
	 * @param   array  $actionableUsers  The list of actionable users to filter
	 *
	 * @return  array  The filtered list
	 *
	 * @since   5.3.0
	 */
	protected function filterActionableUsersToEnsureRemainingSuperUser($actionableUsers)
	{
		$protected  = $this->getProtectedUsers();
		$superUsers = $this->getSuperUsers();

		// If I have any protected Super Users bail out; a Super User is guaranteed to exist on the site.
		$protectedSuper = array_intersect($protected, $superUsers);

		if (count($protectedSuper))
		{
			return $actionableUsers;
		}

		// Remove Super Users from list of blocked users
		return array_diff($actionableUsers, $superUsers);
	}


	/**
	 * Get the protected users' IDs
	 *
	 * @return   int[]
	 *
	 * @since    5.3.0
	 */
	protected function getProtectedUsers()
	{
		$protected = $this->wafParams->getValue($this->settingsKey . '_protected', []);
		$protected = is_string($protected) ? array_map('trim', explode(',', trim($protected))) : $protected;
		$protected = array_map(
			function ($x) {
				return is_array($x) ? $x[0] : $x;
			}, is_array($protected) ? $protected : []
		);
		$protected = array_filter(
			$protected, function ($userID) {
			return (int) trim($userID) != 0;
		}
		);

		return $protected;
	}

	/**
	 * Return the frequency [minutes] for running this feature.
	 *
	 * @return  int
	 *
	 * @since   5.3.0
	 */
	protected function getRunFrequency()
	{
		$minutes = (int) $this->wafParams->getValue($this->settingsKey . '_freq', 60);

		if ($minutes <= 0)
		{
			$minutes = 60;
		}

		return $minutes;
	}
}
