<?php

namespace QUI\MasterLogin;

use PDO;
use QUI;
use QUI\Exception;
use QUI\Interfaces\Users\User as QUIUserInterface;
use QUI\Users\User;
use QUI\Utils\Security\Orthos;

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 implode;
use function in_array;
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';

    /**
     * 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 User|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(?User $user = null): ?array
    {
        if (empty($user)) {
            // Check if the logged-in user is actually a master user logged in as another user.
            $user = QUI::getUserBySession();
            $masterUserIdFromSession = QUI::getSession()->get(self::SESSION_MASTER_USER_ID);

            if (!empty($masterUserIdFromSession)) {
                try {
                    $user = QUI::getUsers()->get($masterUserIdFromSession);
                } catch (\Exception $Exception) {
                    QUI\System\Log::writeException($Exception);
                }
            }
        }

        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($userUuids));
    }

    /**
     * Search list of users that $MasterUser is allowed to log in as
     *
     * @throws Exception
     */
    public static function searchLoginUsers(array $searchParams, null | User $MasterUser = null): array | int
    {
        $Grid = new QUI\Utils\Grid($searchParams);
        $gridParams = $Grid->parseDBParams($searchParams);
        $usersTbl = QUI::getDBTableName('users');
        $addressTbl = QUI::getDBTableName('users_address');
        $binds = [];
        $where = [];
        $count = !empty($searchParams['count']);
        $allowedUserIds = self::getLoginUserIds($MasterUser);

        if ($count) {
            $sql = "SELECT COUNT(*)";
        } else {
            $sql = "SELECT u.`id`, u.`uuid`, u.`username`, u.`firstname` as user_firstname, u.`lastname` as user_lastname, "
                . "a.`firstname` as address_firstname, a.`lastname` as address_lastname, u.`email`, a.`company`";
        }

        $sql .= " FROM `" . $usersTbl . "` as u LEFT JOIN `" . $addressTbl . "` as a ON u.`id` = a.`uid`";
        $where[] = 'u.`active` = 1';

        if ($allowedUserIds !== null) {
            $where[] = 'u.`uuid` IN (\'' . implode('\',\'', $allowedUserIds) . '\')';
        }

        if (!empty($searchParams['search'])) {
            $whereOR = [];

            $searchColumns = [
                'u.`id`',
                'u.`uuid`',
                'u.`username`',
                'u.`firstname`',
                'u.`lastname`',
                'a.`firstname`',
                'a.`lastname`',
                'u.`email`',
                'a.`company`',
                'a.`city`'
            ];

            foreach ($searchColumns as $column) {
                $whereOR[] = $column . ' LIKE :search';
                $binds['search'] = [
                    'value' => '%' . $searchParams['search'] . '%',
                    'type' => PDO::PARAM_STR
                ];
            }

            $where[] = '(' . implode(' OR ', $whereOR) . ')';
        }

        // build WHERE query string
        $sql .= " WHERE " . implode(" AND ", $where);

        // ORDER
        if (!empty($searchParams['sortOn'])) {
            if (!empty($searchParams['sortBy'])) {
                $sortDirection = Orthos::clear($searchParams['sortBy']);
            } else {
                $sortDirection = "ASC";
            }

            $sortColumns = match (Orthos::clear($searchParams['sortOn'])) {
                'name' => [
                    'u.`username`',
                    'u.`firstname`',
                    'u.`lastname`',
                    'a.`firstname`',
                    'a.`lastname`'
                ],
                'email' => [
                    'u.`email`'
                ],
                'company' => [
                    'a.`company`'
                ],
                default => [
                    'u.`id`',
                ],
            };

            $order = [];

            foreach ($sortColumns as $column) {
                $order[] = "$column $sortDirection";
            }

            $sql .= " ORDER BY " . implode(',', $order);
        }

        // LIMIT
        if (!empty($gridParams['limit']) && !$count) {
            $sql .= " LIMIT " . $gridParams['limit'];
        } else {
            if (!$count) {
                $sql .= " LIMIT " . 20;
            }
        }

        $Stmt = QUI::getPDO()->prepare($sql);

        // bind search values
        foreach ($binds as $var => $bind) {
            $Stmt->bindValue(':' . $var, $bind['value'], $bind['type']);
        }

        try {
            $Stmt->execute();
            $result = $Stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (\Exception $Exception) {
            QUI\System\Log::addError(
                self::class . ' :: searchUsers() -> ' . $Exception->getMessage()
            );

            return [];
        }

        if ($count) {
            return (int)current(current($result));
        }

        $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()
        ]);

        $allowedLoginUserIds = self::getLoginUserIds();

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

            throw new PermissionDeniedException();
        }

        $User = QUI::getUsers();
        $Session = QUI::getSession();
        $masterUserId = $Session->get(self::SESSION_MASTER_USER_ID);

        $Slave = QUI::getUsers()->get($slaveUserId);
        $slaveUserId = $Slave->getUUID();

        if (empty($masterUserId)) {
            $SessionUser = QUI::getUserBySession();
            $masterUserId = $SessionUser->getUUID();
            $Session->set(self::SESSION_MASTER_USER_ID, $masterUserId);
        }

        $Session->set('uid', $slaveUserId);

        // Security hash must be updated for slave user
        try {
            QUI::getDataBase()->update(
                $User::table(),
                ['secHash' => $User->getSecHash()],
                ['uuid' => $slaveUserId]
            );
        } catch (\Exception $Exception) {
            self::log('masterlogin.log_in_as.sec_hash_update_failed', [
                'slaveUserId' => $slaveUserId,
                'exception' => $Exception->getMessage(),
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_ERROR);
        }

        // Create log entry
        try {
            QUI::getDataBase()->insert(
                self::getLogTable(),
                [
                    'id' => QUI\Utils\Uuid::get(),
                    'master_id' => $masterUserId,
                    'slave_id' => $slaveUserId,
                    'start' => date('Y-m-d H:i:s'),
                    'sec_hash' => QUI::getUsers()->getSecHash()
                ]
            );

            $Session->set(self::SESSION_LOG_ID, QUI::getDataBase()->getPDO()->lastInsertId('id'));
        } catch (\Exception $Exception) {
            self::log('masterlogin.log_in_as.log_insert_failed', [
                'masterUserId' => $masterUserId,
                'slaveUserId' => $slaveUserId,
                'exception' => $Exception->getMessage(),
                'session' => self::getSessionLogContext()
            ], QUI\System\Log::LEVEL_ERROR);

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

        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()
        ]);

        $Session->set('uid', $masterUserId);
        $Session->set(self::SESSION_MASTER_USER_ID, false);

        // Update log entry
        $logId = $Session->get(self::SESSION_LOG_ID);

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

            return;
        }

        try {
            QUI::getDataBase()->update(
                self::getLogTable(),
                ['end' => date('Y-m-d H:i:s')],
                ['id' => $logId]
            );

            $Session->set(self::SESSION_LOG_ID, false);
        } 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);
        }

        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 User|null $User $User (optional) - [default: session user]
     * @return void
     *
     * @throws PermissionDeniedException
     */
    public static function checkUsePermission(null | User $User = null): void
    {
        if (!self::canUse($User)) {
            throw new PermissionDeniedException();
        }
    }

    /**
     * Check if $User is allowed to use the Master Login feature
     *
     * @param User|null $User $User (optional) - [default: session user]
     * @return bool
     */
    public static function canUse(null | User $User = null): bool
    {
        if (self::isLoggedInAsSlave()) {
            return true;
        }

        if (empty($User)) {
            $User = QUI::getUserBySession();
        }

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

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

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

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

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

        self::$loggingEnabled = true;

        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 = true;
        }

        return self::$loggingEnabled;
    }

    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.
        }
    }

    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()
            ];
        }
    }

    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()
        ];
    }

    public static function getUserGroupsFromDatabase(User $User): array
    {
        try {
            $QueryBuilder = QUI::getQueryBuilder();
            $groups = $QueryBuilder
                ->select('usergroup')
                ->from(QUI\Utils\Doctrine::quoteIdentifier(QUI\Users\Manager::table()))
                ->where($QueryBuilder->expr()->eq('uuid', ':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, ','))));
    }

    public static function normalizeGroupIds(array $groups): array
    {
        $groups = array_values(array_unique(array_filter(array_map('strval', $groups))));
        sort($groups);

        return $groups;
    }
}
