<?php

namespace QUI\MasterLogin;

use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use QUI;
use QUI\Exception;
use QUI\Interfaces\Users\User as QUIUserInterface;
use QUI\Users\User;

use function array_filter;
use function array_map;
use function array_merge;
use function array_search;
use function array_unique;
use function array_values;
use function date;
use function explode;
use function hash_equals;
use function hash_hmac;
use function in_array;
use function is_string;
use function mb_strpos;
use function mb_substr;

/**
 * Class Manager
 *
 * Main manager class for quiqqer/masterlogin
 */
class Manager
{
    const PACKAGE = 'quiqqer/masterlogin';

    /**
     * Tables
     */
    const TBL_LOG = 'masterlogin_log';

    /**
     * Logging
     */
    const LOG_FILE = 'masterlogin-user-events';
    const CONFIG_LOGGING = 'logging';
    const CONFIG_LOGGING_ENABLED = 'enabled';

    /**
     * Permissions
     */
    const PERMISSION_CAN_USE = 'quiqqer.masterlogin.can_use';
    const PERMISSION_SLAVES = 'quiqqer.masterlogin.slaves';
    const PERMISSION_VIEW_AUDIT = 'quiqqer.masterlogin.audit.view';

    /**
     * Session variables
     */
    const SESSION_MASTER_USER_ID = 'session_master_user_id';
    const SESSION_LOG_ID = 'session_log_id';

    protected static ?bool $loggingEnabled = null;

    /**
     * Get IDs of all users that $User is allowed to log in as
     *
     * @param QUIUserInterface|null $user $User (optional) - [default: current session user]
     * @return string[]|null - List of User UUIDs; returns null if there is no restriction to specific users (SU)
     * @throws Exception
     */
    public static function getLoginUserIds(?QUIUserInterface $user = null): ?array
    {
        if (empty($user)) {
            $user = self::getPermissionUserFromSession();
        }

        if ($user->isSU()) {
            return null;
        }

        // Contains list of user and/or groups the current user is allowed to log in as.
        // If this list is empty, every user is allowed.
        $listOfAllowedLoginEntities = $user->getPermission(self::PERMISSION_SLAVES);

        if (empty($listOfAllowedLoginEntities)) {
            return null;
        }

        $slaves = explode(',', $listOfAllowedLoginEntities);

        $userUuids = array_filter($slaves, function ($entry) {
            return mb_strpos($entry, 'u') === 0;
        });

        $groupUuids = array_filter($slaves, function ($entry) {
            return mb_strpos($entry, 'g') === 0;
        });

        // Remove entity identification letter
        $removeIdLetter = function ($value) {
            return mb_substr($value, 1);
        };

        $userUuids = array_map($removeIdLetter, $userUuids);
        $groupUuids = array_map($removeIdLetter, $groupUuids);

        // Parse user IDs from group(s)
        $Groups = QUI::getGroups();

        foreach ($groupUuids as $groupId) {
            try {
                $Group = $Groups->get($groupId);
                $userUuids = array_merge(
                    $userUuids,
                    $Group->getUserIds()
                );
            } catch (\Exception $Exception) {
                QUI\System\Log::writeException($Exception);
            }
        }

        $k = array_search($user->getUUID(), $userUuids);

        if ($k !== false) {
            unset($userUuids[$k]);
        }

        return array_values(array_unique(array_map('strval', $userUuids)));
    }

    /**
     * Search list of users that $MasterUser is allowed to log in as
     *
     * @param array<string, mixed> $searchParams
     * @return array<int, array<string, mixed>>|int
     *
     * @throws Exception
     */
    public static function searchLoginUsers(array $searchParams, ?QUIUserInterface $MasterUser = null): array | int
    {
        $MasterUser ??= self::getPermissionUserFromSession();
        $Grid = new QUI\Utils\Grid($searchParams);
        $gridParams = $Grid->parseDBParams($searchParams);
        $Connection = QUI::getDataBaseConnection();
        $Platform = $Connection->getDatabasePlatform();
        $QueryBuilder = $Connection->createQueryBuilder();
        $usersTbl = QUI\Utils\Doctrine::quoteIdentifier(QUI::getDBTableName('users'));
        $addressTbl = QUI\Utils\Doctrine::quoteIdentifier(QUI::getDBTableName('users_address'));
        $count = !empty($searchParams['count']);
        $allowedUserIds = self::getLoginUserIds($MasterUser);

        if ($allowedUserIds === []) {
            return $count ? 0 : [];
        }

        $userId = 'u.' . $Platform->quoteSingleIdentifier('id');
        $userUuid = 'u.' . $Platform->quoteSingleIdentifier('uuid');
        $username = 'u.' . $Platform->quoteSingleIdentifier('username');
        $userFirstname = 'u.' . $Platform->quoteSingleIdentifier('firstname');
        $userLastname = 'u.' . $Platform->quoteSingleIdentifier('lastname');
        $userEmail = 'u.' . $Platform->quoteSingleIdentifier('email');
        $userActive = 'u.' . $Platform->quoteSingleIdentifier('active');
        $userSuper = 'u.' . $Platform->quoteSingleIdentifier('su');
        $addressUserUuid = 'a.' . $Platform->quoteSingleIdentifier('userUuid');
        $addressFirstname = 'a.' . $Platform->quoteSingleIdentifier('firstname');
        $addressLastname = 'a.' . $Platform->quoteSingleIdentifier('lastname');
        $addressCompany = 'a.' . $Platform->quoteSingleIdentifier('company');
        $addressCity = 'a.' . $Platform->quoteSingleIdentifier('city');

        if ($count) {
            $QueryBuilder->select('COUNT(DISTINCT ' . $userId . ')');
        } else {
            $QueryBuilder->select(
                $userId . ' AS id',
                $userUuid . ' AS uuid',
                $username . ' AS username',
                $userFirstname . ' AS user_firstname',
                $userLastname . ' AS user_lastname',
                $addressFirstname . ' AS address_firstname',
                $addressLastname . ' AS address_lastname',
                $userEmail . ' AS email',
                $addressCompany . ' AS company'
            );
        }

        $QueryBuilder
            ->from($usersTbl, 'u')
            ->leftJoin('u', $addressTbl, 'a', $userUuid . ' = ' . $addressUserUuid)
            ->where($QueryBuilder->expr()->eq($userActive, ':active'))
            ->setParameter('active', 1);

        if (!$MasterUser->isSU()) {
            $QueryBuilder
                ->andWhere($QueryBuilder->expr()->eq($userSuper, ':superUser'))
                ->setParameter('superUser', 0);
        }

        if ($allowedUserIds !== null) {
            $QueryBuilder
                ->andWhere($userUuid . ' IN (:allowedUserIds)')
                ->setParameter('allowedUserIds', $allowedUserIds, ArrayParameterType::STRING);
        }

        if (isset($searchParams['search']) && is_scalar($searchParams['search'])) {
            $search = trim((string)$searchParams['search']);
            $searchColumns = [
                $userId,
                $userUuid,
                $username,
                $userFirstname,
                $userLastname,
                $addressFirstname,
                $addressLastname,
                $userEmail,
                $addressCompany,
                $addressCity
            ];

            if ($search !== '') {
                $searchConditions = array_map(
                    static fn (string $column): string => $column . ' LIKE :search',
                    $searchColumns
                );

                $QueryBuilder
                    ->andWhere('(' . $QueryBuilder->expr()->or(...$searchConditions) . ')')
                    ->setParameter('search', '%' . $search . '%');
            }
        }

        if (!$count) {
            $sortOn = isset($searchParams['sortOn']) && is_string($searchParams['sortOn'])
                ? $searchParams['sortOn']
                : 'id';
            $sortDirection = isset($searchParams['sortBy']) && is_string($searchParams['sortBy'])
                ? strtoupper($searchParams['sortBy'])
                : 'ASC';

            if (!in_array($sortDirection, ['ASC', 'DESC'], true)) {
                $sortDirection = 'ASC';
            }

            $sortColumns = match ($sortOn) {
                'name' => [
                    $username,
                    $userFirstname,
                    $userLastname,
                    $addressFirstname,
                    $addressLastname
                ],
                'email' => [$userEmail],
                'company' => [$addressCompany],
                default => [$userId],
            };

            foreach ($sortColumns as $column) {
                $QueryBuilder->addOrderBy($column, $sortDirection);
            }

            QUI\Utils\Doctrine::applyLimit($QueryBuilder, $gridParams['limit'] ?? 20);
        }

        try {
            if ($count) {
                return (int)$QueryBuilder->executeQuery()->fetchOne();
            }

            $result = $QueryBuilder->executeQuery()->fetchAllAssociative();
        } catch (\Exception $Exception) {
            QUI\System\Log::addError(
                self::class . ' :: searchUsers() -> ' . $Exception->getMessage()
            );

            return [];
        }

        $users = [];

        foreach ($result as $row) {
            $userId = (int)$row['id'];

            if (isset($users[$userId])) {
                continue;
            }

            // Name
            $name = $row['user_firstname'] . ' ' . $row['user_lastname'];

            if (!empty($row['address_firstname']) && !empty($row['address_lastname'])) {
                $name = $row['address_firstname'] . ' ' . $row['address_lastname'];
            }

            if (empty(trim($name))) {
                $name = $row['username'];
            }

            $users[$userId] = [
                'id' => $userId,
                'uuid' => $row['uuid'],
                'name' => $name,
                'email' => $row['email'],
                'company' => $row['company']
            ];
        }

        return array_values($users);
    }

    /**
     * Switches the current session user to $slaveUserId (requires permission!)
     *
     * @param string $slaveUserId
     * @return void
     *
     * @throws PermissionDeniedException
     * @throws Exception
     */
    public static function logInAs(string $slaveUserId): void
    {
        self::log('masterlogin.log_in_as.begin', [
            'requestedSlaveUserId' => $slaveUserId,
            'session' => self::getSessionLogContext()
        ]);

        if (self::isLoggedInAsSlave()) {
            self::log('masterlogin.log_in_as.rejected_nested_switch', [
                'requestedSlaveUserId' => $slaveUserId,
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_WARNING);

            throw new PermissionDeniedException();
        }

        self::checkUsePermission();
        $Users = QUI::getUsers();
        $Session = QUI::getSession();
        $Master = self::getPermissionUserFromSession();
        $masterUserId = (string)$Master->getUUID();
        $allowedLoginUserIds = self::getLoginUserIds($Master);

        try {
            $Slave = $Users->get($slaveUserId);
        } catch (\Exception $Exception) {
            self::log('masterlogin.log_in_as.invalid_target', [
                'requestedSlaveUserId' => $slaveUserId,
                'exception' => $Exception->getMessage(),
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_WARNING);

            throw new PermissionDeniedException();
        }

        if (
            !($Slave instanceof User)
            || !$Slave->isActive()
            || $Slave->isDeleted()
            || (!$Master->isSU() && $Slave->isSU())
        ) {
            self::log('masterlogin.log_in_as.invalid_target', [
                'requestedSlaveUserId' => $slaveUserId,
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_WARNING);

            throw new PermissionDeniedException();
        }

        $slaveUserId = (string)$Slave->getUUID();

        if (
            $slaveUserId === $masterUserId
            || ($allowedLoginUserIds !== null && !in_array($slaveUserId, $allowedLoginUserIds, true))
        ) {
            self::log('masterlogin.log_in_as.permission_denied', [
                'requestedSlaveUserId' => $slaveUserId,
                'allowedLoginUserIds' => $allowedLoginUserIds,
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_WARNING);

            throw new PermissionDeniedException();
        }

        $logId = QUI\Utils\Uuid::get();
        $Connection = QUI::getDataBaseConnection();
        $Platform = $Connection->getDatabasePlatform();

        try {
            $Connection->beginTransaction();

            $Connection->update(
                QUI\Utils\Doctrine::quoteIdentifier($Users::table()),
                [$Platform->quoteSingleIdentifier('secHash') => $Users->getSecHash()],
                [$Platform->quoteSingleIdentifier('uuid') => $slaveUserId]
            );

            $Connection->insert(
                QUI\Utils\Doctrine::quoteIdentifier(self::getLogTable()),
                [
                    $Platform->quoteSingleIdentifier('id') => $logId,
                    $Platform->quoteSingleIdentifier('master_id') => $masterUserId,
                    $Platform->quoteSingleIdentifier('slave_id') => $slaveUserId,
                    $Platform->quoteSingleIdentifier('start') => date('Y-m-d H:i:s'),
                    $Platform->quoteSingleIdentifier('sec_hash') => $Users->getSecHash()
                ]
            );

            $Connection->commit();
        } catch (\Exception $Exception) {
            if ($Connection->isTransactionActive()) {
                $Connection->rollBack();
            }

            self::log('masterlogin.log_in_as.persistence_failed', [
                'masterUserId' => $masterUserId,
                'slaveUserId' => $slaveUserId,
                'exception' => $Exception->getMessage(),
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_ERROR);

            QUI\System\Log::writeException($Exception);

            throw $Exception;
        }

        $Session->set(self::SESSION_MASTER_USER_ID, $masterUserId);
        $Session->set(self::SESSION_LOG_ID, $logId);
        $Session->set('uid', $slaveUserId);

        self::log('masterlogin.log_in_as.success', [
            'masterUserId' => $masterUserId,
            'slaveUserId' => $slaveUserId,
            'slaveUser' => self::getUserLogContext($Slave),
            'session' => self::getSessionLogContext()
        ]);
    }

    /**
     * Switches the current session user to the original user
     *
     * @return void
     */
    public static function logBack(): void
    {
        if (!self::isLoggedInAsSlave()) {
            self::log('masterlogin.log_back.skipped_not_slave', [
                'session' => self::getSessionLogContext()
            ]);

            return;
        }

        $Session = QUI::getSession();

        $masterUserId = $Session->get(self::SESSION_MASTER_USER_ID);
        $slaveUserId = $Session->get('uid');

        self::log('masterlogin.log_back.begin', [
            'masterUserId' => $masterUserId,
            'slaveUserId' => $slaveUserId,
            'session' => self::getSessionLogContext()
        ]);

        $logId = $Session->get(self::SESSION_LOG_ID);
        $masterRestored = false;
        $logClosed = empty($logId);

        try {
            if (!is_string($masterUserId) && !is_int($masterUserId)) {
                throw new PermissionDeniedException();
            }

            $Master = QUI::getUsers()->get($masterUserId);

            if (!($Master instanceof User) || !$Master->isActive() || $Master->isDeleted()) {
                throw new PermissionDeniedException();
            }

            $Session->set('uid', $Master->getUUID());
            $masterRestored = true;
        } catch (\Exception $Exception) {
            $Session->set('uid', 0);

            self::log('masterlogin.log_back.invalid_master', [
                'masterUserId' => $masterUserId,
                'slaveUserId' => $slaveUserId,
                'exception' => $Exception->getMessage(),
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_ERROR);
        }

        $Session->set(self::SESSION_MASTER_USER_ID, false);
        $Session->set(self::SESSION_LOG_ID, false);

        if (!empty($logId)) {
            try {
                $Connection = QUI::getDataBaseConnection();
                $Platform = $Connection->getDatabasePlatform();

                $Connection->update(
                    QUI\Utils\Doctrine::quoteIdentifier(self::getLogTable()),
                    [$Platform->quoteSingleIdentifier('end') => date('Y-m-d H:i:s')],
                    [$Platform->quoteSingleIdentifier('id') => $logId]
                );
                $logClosed = true;
            } catch (\Exception $Exception) {
                self::log('masterlogin.log_back.log_update_failed', [
                    'masterUserId' => $masterUserId,
                    'slaveUserId' => $slaveUserId,
                    'logId' => $logId,
                    'exception' => $Exception->getMessage(),
                    'session' => self::getSessionLogContext()
                ], QUI\System\Log::LEVEL_ERROR);

                QUI\System\Log::writeException($Exception);
            }
        }

        if (!$masterRestored || !$logClosed) {
            self::log('masterlogin.log_back.completed_with_errors', [
                'masterUserId' => $masterUserId,
                'slaveUserId' => $slaveUserId,
                'logId' => $logId,
                'masterRestored' => $masterRestored,
                'logClosed' => $logClosed,
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_WARNING);

            return;
        }

        if (empty($logId)) {
            self::log('masterlogin.log_back.success_without_log_id', [
                'masterUserId' => $masterUserId,
                'slaveUserId' => $slaveUserId,
                'session' => self::getSessionLogContext()
            ]);

            return;
        }

        self::log('masterlogin.log_back.success', [
            'masterUserId' => $masterUserId,
            'slaveUserId' => $slaveUserId,
            'logId' => $logId,
            'session' => self::getSessionLogContext()
        ]);
    }

    /**
     * Checks if the current Session user is logged in as a slave user
     *
     * @return bool
     */
    public static function isLoggedInAsSlave(): bool
    {
        $masterUserId = QUI::getSession()->get(self::SESSION_MASTER_USER_ID);
        return !empty($masterUserId);
    }

    /**
     * Check if $User is allowed to use the Master Login feature and throws and exception if not
     *
     * @param QUIUserInterface|null $User $User (optional) - [default: session user]
     * @return void
     *
     * @throws PermissionDeniedException
     */
    public static function checkUsePermission(?QUIUserInterface $User = null): void
    {
        if (!self::canUse($User)) {
            throw new PermissionDeniedException();
        }
    }

    /**
     * Check if $User is allowed to use the Master Login feature
     *
     * @param QUIUserInterface|null $User $User (optional) - [default: session user]
     * @return bool
     */
    public static function canUse(?QUIUserInterface $User = null): bool
    {
        if (empty($User)) {
            try {
                $User = self::getPermissionUserFromSession();
            } catch (\Exception) {
                return false;
            }
        }

        if ($User->isSU()) {
            return true;
        }

        if (!method_exists($User, 'hasPermission')) {
            return false;
        }

        return (bool)$User->hasPermission(self::PERMISSION_CAN_USE);
    }

    public static function getCsrfToken(): string
    {
        return hash_hmac(
            'sha256',
            self::PACKAGE . ':' . QUI::getSession()->getId(),
            (string)QUI::conf('globals', 'salt')
        );
    }

    public static function checkCsrfToken(string $token): void
    {
        $expectedToken = self::getCsrfToken();

        if ($token === '' || !hash_equals($expectedToken, $token)) {
            throw new PermissionDeniedException();
        }
    }

    private static function getPermissionUserFromSession(): QUIUserInterface
    {
        $masterUserId = QUI::getSession()->get(self::SESSION_MASTER_USER_ID);

        if (empty($masterUserId)) {
            return QUI::getUserBySession();
        }

        if (!is_string($masterUserId) && !is_int($masterUserId)) {
            throw new PermissionDeniedException();
        }

        try {
            return QUI::getUsers()->get($masterUserId);
        } catch (\Exception $Exception) {
            self::log('masterlogin.permission.invalid_master', [
                'masterUserId' => $masterUserId,
                'exception' => $Exception->getMessage(),
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_ERROR);

            throw new PermissionDeniedException();
        }
    }

    /**
     * Get log table name
     *
     * @return string
     */
    public static function getLogTable(): string
    {
        return QUI::getDBTableName(self::TBL_LOG);
    }

    /**
     * Delete audit entries whose Master Login session started more than $days ago.
     */
    public static function deleteLogEntriesOlderThanDays(int $days): void
    {
        if ($days < 1) {
            throw new \InvalidArgumentException('The retention period must be at least one day.');
        }

        $cutoff = (new DateTimeImmutable())->modify('-' . $days . ' days')->format('Y-m-d H:i:s');
        $Connection = QUI::getDataBaseConnection();
        $Platform = $Connection->getDatabasePlatform();

        $Connection->createQueryBuilder()
            ->delete(QUI\Utils\Doctrine::quoteIdentifier(self::getLogTable()))
            ->where($Platform->quoteSingleIdentifier('start') . ' <= :cutoff')
            ->setParameter('cutoff', $cutoff)
            ->executeStatement();
    }

    public static function isLoggingEnabled(): bool
    {
        if (self::$loggingEnabled !== null) {
            return self::$loggingEnabled;
        }

        self::$loggingEnabled = false;

        try {
            $Config = QUI::getPackage(self::PACKAGE)->getConfig();
            $value = $Config?->get(self::CONFIG_LOGGING, self::CONFIG_LOGGING_ENABLED);

            if ($value !== null && $value !== '') {
                self::$loggingEnabled = (bool)(int)$value;
            }
        } catch (\Throwable) {
            self::$loggingEnabled = false;
        }

        return self::$loggingEnabled;
    }

    /**
     * @param array<string, mixed> $context
     */
    public static function log(
        string $event,
        array $context = [],
        int $level = QUI\System\Log::LEVEL_INFO
    ): void {
        if (!self::isLoggingEnabled()) {
            return;
        }

        $context['masterLoginEvent'] = $event;

        try {
            QUI\System\Log::write(
                '[masterlogin] ' . $event,
                $level,
                $context,
                self::LOG_FILE,
                true
            );
        } catch (\Throwable) {
            // Logging must never break authentication or user persistence.
        }
    }

    /**
     * @return array<string, mixed>
     */
    public static function getSessionLogContext(): array
    {
        try {
            $Session = QUI::getSession();

            return [
                'uid' => $Session->get('uid'),
                'masterUserId' => $Session->get(self::SESSION_MASTER_USER_ID),
                'logId' => $Session->get(self::SESSION_LOG_ID),
                'isLoggedInAsSlave' => self::isLoggedInAsSlave()
            ];
        } catch (\Throwable $Exception) {
            return [
                'error' => $Exception->getMessage()
            ];
        }
    }

    /**
     * @return array<string, mixed>
     */
    public static function getUserLogContext(QUIUserInterface $User): array
    {
        return [
            'id' => $User->getId(),
            'uuid' => $User->getUUID(),
            'username' => $User->getUsername(),
            'groups' => $User->getGroups(false),
            'active' => $User->isActive(),
            'su' => $User->isSU()
        ];
    }

    /**
     * @return list<string>
     */
    public static function getUserGroupsFromDatabase(User $User): array
    {
        try {
            $QueryBuilder = QUI::getQueryBuilder();
            $uuidColumn = QUI\Utils\Doctrine::quoteIdentifier('uuid');
            $usergroupColumn = QUI\Utils\Doctrine::quoteIdentifier('usergroup');
            $groups = $QueryBuilder
                ->select($usergroupColumn)
                ->from(QUI\Utils\Doctrine::quoteIdentifier(QUI\Users\Manager::table()))
                ->where($QueryBuilder->expr()->eq($uuidColumn, ':uuid'))
                ->setParameter('uuid', $User->getUUID())
                ->setMaxResults(1)
                ->executeQuery()
                ->fetchOne();
        } catch (\Throwable) {
            return [];
        }

        if (!is_string($groups) || trim($groups, ',') === '') {
            return [];
        }

        return array_values(array_filter(
            explode(',', trim($groups, ',')),
            static fn (string $group): bool => $group !== ''
        ));
    }

    /**
     * @param array<int|string, mixed> $groups
     * @return list<string>
     */
    public static function normalizeGroupIds(array $groups): array
    {
        $groups = array_values(array_unique(array_filter(array_map('strval', $groups))));
        sort($groups);

        return $groups;
    }
}
