<?php
namespace Misuzu\Perms;

use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\DateTime;
use Index\Environment;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Index\Data\IDbStatement;
use Misuzu\Forum\ForumCategories;
use Misuzu\Forum\ForumCategoryInfo;
use Misuzu\Users\RoleInfo;
use Misuzu\Users\UserInfo;

class Permissions {
    // limiting this to 53-bit in case it ever has to be sent to javascript or any other implicit float language
    // For More Information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER
    // it's still a ways up from the 31-bit of the old permission system which only existed because i developed on a 32-bit laptop for a bit
    public const PERMS_MIN = 0;
    public const PERMS_MAX = 9007199254740991;

    private IDbConnection $dbConn;
    private DbStatementCache $cache;

    public function __construct(IDbConnection $dbConn) {
        $this->dbConn = $dbConn;
        $this->cache = new DbStatementCache($dbConn);
    }

    // this method is purely intended for getting the permission data for a single entity
    // it should not be used to do actual permission checks
    public function getPermissionInfo(
        UserInfo|string|null $userInfo = null,
        RoleInfo|string|null $roleInfo = null,
        ForumCategoryInfo|string|null $forumCategoryInfo = null,
        array|string|null $categoryNames = null,
    ): PermissionInfo|array|null {
        $hasUserInfo = $userInfo !== null;
        $hasRoleInfo = $roleInfo !== null;
        if($hasUserInfo && $hasRoleInfo)
            throw new InvalidArgumentException('$userInfo and $roleInfo may not be set at the same time.');

        $hasForumCategoryInfo = $forumCategoryInfo !== null;
        $hasCategoryName = $categoryNames !== null;
        $categoryNamesIsArray = $hasCategoryName && is_array($categoryNames);
        if($categoryNamesIsArray && empty($categoryNames))
            throw new InvalidArgumentException('$categoryNames may not be empty if it is an array.');

        $query = 'SELECT user_id, role_id, forum_id, perms_category, perms_allow, perms_deny FROM msz_perms';
        $query .= sprintf(' WHERE user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
        $query .= sprintf(' AND role_id %s', $hasRoleInfo ? '= ?' : 'IS NULL');
        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
        if($hasCategoryName)
            $query .= ' AND perms_category ' . ($categoryNamesIsArray ? sprintf('IN (%s)', DbTools::prepareListString($categoryNames)) : '= ?');

        $args = 0;
        $stmt = $this->cache->get($query);
        if($hasUserInfo)
            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
        if($hasRoleInfo)
            $stmt->addParameter(++$args, $roleInfo instanceof RoleInfo ? $roleInfo->getId() : $roleInfo);
        if($hasForumCategoryInfo)
            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
        if($hasCategoryName) {
            if($categoryNamesIsArray) {
                foreach($categoryNames as $name)
                    $stmt->addParameter(++$args, $name);
            } else
                $stmt->addParameter(++$args, $categoryNames);
        }
        $stmt->execute();

        $result = $stmt->getResult();

        if(is_string($categoryNames))
            return $result->next() ? new PermissionInfo($result) : null;

        $perms = [];
        while($result->next())
            $perms[$result->getString(3)] = new PermissionInfo($result);

        return $perms;
    }

    public function setPermissions(
        string $categoryName,
        int $allow,
        int $deny,
        UserInfo|string|null $userInfo = null,
        RoleInfo|string|null $roleInfo = null,
        ForumCategoryInfo|string|null $forumCategoryInfo = null
    ): void {
        if($allow < self::PERMS_MIN || $allow > self::PERMS_MAX)
            throw new InvalidArgumentException('$allow must be an positive 53-bit integer.');
        if($deny < self::PERMS_MIN || $deny > self::PERMS_MAX)
            throw new InvalidArgumentException('$allow must be an positive 53-bit integer.');
        if($userInfo !== null && $roleInfo !== null)
            throw new InvalidArgumentException('$userInfo and $roleInfo may not be set at the same time.');

        // because of funny technical reasons we have to delete separately
        $this->removePermissions($categoryName, $userInfo, $roleInfo, $forumCategoryInfo);

        // don't insert zeroes
        if($allow === 0 && $deny === 0)
            return;

        $stmt = $this->cache->get('INSERT INTO msz_perms (user_id, role_id, forum_id, perms_category, perms_allow, perms_deny) VALUES (?, ?, ?, ?, ?, ?)');
        $stmt->addParameter(1, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
        $stmt->addParameter(2, $roleInfo instanceof RoleInfo ? $roleInfo->getId() : $roleInfo);
        $stmt->addParameter(3, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
        $stmt->addParameter(4, $categoryName);
        $stmt->addParameter(5, $allow);
        $stmt->addParameter(6, $deny);
        $stmt->execute();
    }

    public function removePermissions(
        array|string|null $categoryNames,
        UserInfo|string|null $userInfo = null,
        RoleInfo|string|null $roleInfo = null,
        ForumCategoryInfo|string|null $forumCategoryInfo = null
    ): void {
        $hasUserInfo = $userInfo !== null;
        $hasRoleInfo = $roleInfo !== null;
        $hasForumCategoryInfo = $forumCategoryInfo !== null;
        $hasCategoryNames = $categoryNames !== null;
        $categoryNamesIsArray = $hasCategoryNames && is_array($categoryNames);

        $query = 'DELETE FROM msz_perms';
        $query .= sprintf(' WHERE user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
        $query .= sprintf(' AND role_id %s', $hasRoleInfo ? '= ?' : 'IS NULL');
        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
        if($hasCategoryNames)
            $query .= ' AND perms_category ' . ($categoryNamesIsArray ? sprintf('IN (%s)', DbTools::prepareListString($categoryNames)) : '= ?');

        $args = 0;
        $stmt = $this->cache->get($query);
        if($hasUserInfo)
            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
        if($hasRoleInfo)
            $stmt->addParameter(++$args, $roleInfo instanceof RoleInfo ? $roleInfo->getId() : $roleInfo);
        if($hasForumCategoryInfo)
            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
        if($categoryNamesIsArray) {
            foreach($categoryNames as $name)
                $stmt->addParameter(++$args, $name);
        } else
            $stmt->addParameter(++$args, $categoryNames);
        $stmt->execute();
    }

    public function checkPermissions(
        string $categoryName,
        int $perms,
        UserInfo|string|null $userInfo = null,
        ForumCategoryInfo|string|null $forumCategoryInfo = null
    ): int {
        $hasUserInfo = $userInfo !== null;
        $hasForumCategoryInfo = $forumCategoryInfo !== null;

        $query = 'SELECT perms_calculated & ? FROM msz_perms_calculated WHERE perms_category = ?';
        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
        $query .= sprintf(' AND user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');

        $args = 0;
        $stmt = $this->cache->get($query);
        $stmt->addParameter(++$args, $perms);
        $stmt->addParameter(++$args, $categoryName);
        if($hasForumCategoryInfo)
            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
        if($hasUserInfo)
            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
        $stmt->execute();

        $result = $stmt->getResult();
        return $result->next() ? $result->getInteger(0) : 0;
    }

    public function getPermissions(
        string|array $categoryNames,
        UserInfo|string|null $userInfo = null,
        ForumCategoryInfo|string|null $forumCategoryInfo = null
    ): PermissionResult|stdClass {
        $categoryNamesIsArray = is_array($categoryNames);
        if($categoryNamesIsArray && empty($categoryNames))
            throw new InvalidArgumentException('$categoryNames may not be an empty array.');

        $hasUserInfo = $userInfo !== null;
        $hasForumCategoryInfo = $forumCategoryInfo !== null;

        $query = 'SELECT perms_category, perms_calculated FROM msz_perms_calculated';
        $query .= ' WHERE perms_category ' . ($categoryNamesIsArray ? sprintf('IN (%s)', DbTools::prepareListString($categoryNames)) : '= ?');
        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
        $query .= sprintf(' AND user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
        $query .= ' GROUP BY perms_category';

        $args = 0;
        $stmt = $this->cache->get($query);

        if($categoryNamesIsArray) {
            foreach($categoryNames as $name)
                $stmt->addParameter(++$args, $name);
        } else
            $stmt->addParameter(++$args, $categoryNames);
        if($hasForumCategoryInfo)
            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
        if($hasUserInfo)
            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
        $stmt->execute();

        $result = $stmt->getResult();
        if(!$categoryNamesIsArray)
            return new PermissionResult($result->next() ? $result->getInteger(1) : 0);

        $results = [];
        while($result->next())
            $results[$result->getString(0)] = $result->getInteger(1);

        $sets = new stdClass;
        foreach($categoryNames as $categoryName)
            $sets->{$categoryName} = new PermissionResult($results[$categoryName] ?? 0);

        return $sets;
    }

    // precalculates all permissions for fast lookups
    public function precalculatePermissions(ForumCategories $forumCategories, array $userIds = []): void {
        $suppliedUsers = !empty($userIds);
        $doGuest = !$suppliedUsers;

        if($suppliedUsers) {
            self::precalculatePermissionsLog('Removing calculations for given user ids...');
            $stmt = $this->cache->get('DELETE FROM msz_perms_calculated WHERE user_id = ?');
            foreach($userIds as $userId) {
                $stmt->addParameter(1, $userId);
                $stmt->execute();
            }
        } else {
            self::precalculatePermissionsLog('Loading list of user IDs...');
            $result = $this->dbConn->query('SELECT user_id FROM msz_users');
            while($result->next())
                $userIds[] = $result->getString(0);

            self::precalculatePermissionsLog('Clearing existing precalculations...');
            $this->dbConn->execute('TRUNCATE msz_perms_calculated');
        }

        self::precalculatePermissionsLog('Creating inserter statement...');
        $insert = $this->cache->get('INSERT INTO msz_perms_calculated (user_id, forum_id, perms_category, perms_calculated) VALUES (?, ?, ?, ?)');

        if($doGuest) {
            self::precalculatePermissionsLog('Calculating guest permissions...');
            $result = $this->dbConn->query('SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IS NULL AND user_id IS NULL AND role_id IS NULL GROUP BY perms_category');
            $insert->addParameter(1, null);
            $insert->addParameter(2, null);
            while($result->next()) {
                $category = $result->getString(0);
                $perms = $result->getInteger(1);
                if($perms === 0)
                    continue;

                self::precalculatePermissionsLog('Inserting guest permissions for category %s with value %x...', $category, $perms);
                $insert->addParameter(3, $category);
                $insert->addParameter(4, $perms);
                $insert->execute();
            }
        }

        self::precalculatePermissionsLog('Calculating user permissions...');
        $stmt = $this->cache->get('SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IS NULL AND (user_id = ? OR role_id IN (SELECT role_id FROM msz_users_roles WHERE user_id = ?)) GROUP BY perms_category');
        foreach($userIds as $userId) {
            $insert->reset();
            $insert->addParameter(1, $userId);
            $insert->addParameter(2, null);

            $stmt->reset();
            $stmt->addParameter(1, $userId);
            $stmt->addParameter(2, $userId);
            $stmt->execute();

            $result = $stmt->getResult();
            while($result->next()) {
                $category = $result->getString(0);
                $perms = $result->getInteger(1);
                if($perms === 0)
                    continue;

                self::precalculatePermissionsLog('Inserting user #%s permissions for category %s with value %x...', $userId, $category, $perms);
                $insert->addParameter(3, $category);
                $insert->addParameter(4, $perms);
                $insert->execute();
            }
        }

        self::precalculatePermissionsLog('Loading list of forum categories...');
        $forumCats = $forumCategories->getCategories(asTree: true);
        foreach($forumCats as $forumCat)
            $this->precalculatePermissionsForForumCategory($insert, $userIds, $forumCat, $doGuest);

        self::precalculatePermissionsLog('Finished permission precalculations!');
    }

    private function precalculatePermissionsForForumCategory(IDbStatement $insert, array $userIds, object $forumCat, bool $doGuest, array $catIds = []): void {
        $catIds[] = $currentCatId = $forumCat->info->getId();
        self::precalculatePermissionsLog('Precalcuting permissions for forum category #%s (%s)...', $currentCatId, implode(' <- ', $catIds));

        if($doGuest) {
            self::precalculatePermissionsLog('Calculating guest permission for forum category #%s...', $currentCatId);
            $args = 0;
            $stmt = $this->cache->get(sprintf(
                'SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IN (%s) AND user_id IS NULL AND role_id IS NULL GROUP BY perms_category',
                DbTools::prepareListString($catIds)
            ));
            foreach($catIds as $catId)
                $stmt->addParameter(++$args, $catId);
            $stmt->execute();

            $insert->reset();
            $insert->addParameter(1, null);
            $insert->addParameter(2, $currentCatId);
            $result = $stmt->getResult();
            while($result->next()) {
                $category = $result->getString(0);
                $perms = $result->getInteger(1);
                if($perms === 0)
                    continue;

                self::precalculatePermissionsLog('Inserting guest permissions for category %s with value %x for forum category #%s...', $category, $perms, $currentCatId);
                $insert->addParameter(3, $category);
                $insert->addParameter(4, $perms);
                $insert->execute();
            }
        }

        $args = 0;
        $stmt = $this->cache->get(sprintf(
            'SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IN (%s) AND (user_id = ? OR role_id IN (SELECT role_id FROM msz_users_roles WHERE user_id = ?)) GROUP BY perms_category',
            DbTools::prepareListString($catIds)
        ));
        foreach($catIds as $catId)
            $stmt->addParameter(++$args, $catId);
        $startArgs = $args;
        foreach($userIds as $userId) {
            $args = $startArgs;
            $stmt->addParameter(++$args, $userId);
            $stmt->addParameter(++$args, $userId);
            $stmt->execute();

            $insert->reset();
            $insert->addParameter(1, $userId);
            $insert->addParameter(2, $currentCatId);
            $result = $stmt->getResult();
            while($result->next()) {
                $category = $result->getString(0);
                $perms = $result->getInteger(1);
                if($perms === 0)
                    continue;

                self::precalculatePermissionsLog('Inserting user #%s permissions for category %s with value %x for forum category #%s...', $userId, $category, $perms, $currentCatId);
                $insert->addParameter(3, $category);
                $insert->addParameter(4, $perms);
                $insert->execute();
            }
        }

        foreach($forumCat->children as $forumChild)
            $this->precalculatePermissionsForForumCategory($insert, $userIds, $forumChild, $doGuest, $catIds);
    }

    private static function precalculatePermissionsLog(string $fmt, ...$args): void {
        if(!Environment::isConsole())
            return;

        echo DateTime::now()->format('[H:i:s.u] ');
        vprintf($fmt, $args);
        echo PHP_EOL;
    }
}
