<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Backend\Security\SudoMode\Access;

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;

/**
 * Wrapper for storing `AccessClaim` and `AccessGrant` items in the backend user session storage.
 *
 * @internal
 */
#[Autoconfigure(public: true)]
readonly class AccessStorage
{
    protected const CLAIM_KEY = 'backend.sudo-mode.claim';
    protected const GRANT_KEY = 'backend.sudo-mode.grant';

    protected int $currentTimestamp;

    public function __construct(
        protected AccessFactory $factory,
        protected LoggerInterface $logger,
    ) {
        $this->currentTimestamp = (int)($GLOBALS['EXEC_TIME'] ?? time());
    }

    public function findGrantsBySubject(AccessSubjectInterface $subject): array
    {
        $relevantItems = array_filter(
            $this->fetchGrants(),
            // either group matches (if given), or subject matches
            fn(array $item) => $this->subjectMatchesItem($subject, $item)
        );
        return array_map($this->factory->buildGrantFromArray(...), $relevantItems);
    }

    public function addGrant(AccessGrant $grant): void
    {
        $items = $this->fetchGrants();
        $identity = $grant->subject->getIdentity();
        if (isset($items[$identity])) {
            $this->logger->warning(
                sprintf('Grant %s does already exist', $identity),
                $grant->jsonSerialize()
            );
        }
        $items[$identity] = $grant;
        $this->commitItems(self::GRANT_KEY, $items);
    }

    public function removeGrant(AccessGrant $grant): void
    {
        $items = $this->fetchGrants();
        $identity = $grant->subject->getIdentity();
        if (!isset($items[$identity])) {
            $this->logger->warning(
                sprintf('Grant %s does not exist', $identity),
                $grant->jsonSerialize()
            );
        }
        unset($items[$identity]);
        $this->commitItems(self::GRANT_KEY, $items);
    }

    public function findClaimById(string $id): ?AccessClaim
    {
        $item = $this->fetchClaims()[$id] ?? null;
        return !empty($item) ? $this->factory->buildClaimFromArray($item) : null;
    }

    public function findClaimBySubject(AccessSubjectInterface $subject): ?AccessClaim
    {
        foreach ($this->fetchClaims() as $item) {
            if ($this->subjectMatchesItem($subject, $item)) {
                return $this->factory->buildClaimFromArray($item);
            }
        }
        return null;
    }

    public function addClaim(AccessClaim $claim): void
    {
        $items = $this->fetchClaims();
        $items[$claim->id] = $claim;
        $this->commitItems(self::CLAIM_KEY, $items);
    }

    public function removeClaim(AccessClaim $claim): void
    {
        $items = $this->fetchClaims();
        unset($items[$claim->id]);
        $this->commitItems(self::CLAIM_KEY, $items);
    }

    protected function fetchGrants(): array
    {
        return $this->fetchItems(self::GRANT_KEY);
    }

    protected function fetchClaims(): array
    {
        return $this->fetchItems(self::CLAIM_KEY);
    }

    protected function fetchItems(string $sessionKey): array
    {
        $sessionData = $this->getBackendUser()->getSessionData($sessionKey);
        $items = json_decode((string)$sessionData, true, 16) ?? [];
        $purgedItems = array_filter(
            $items,
            fn(array $item) => ($item['expiration'] ?? 0) >= $this->currentTimestamp
        );
        if (count($purgedItems) < count($items)) {
            $this->commitItems($sessionKey, $purgedItems);
        }
        return $purgedItems;
    }

    protected function commitItems(string $sessionKey, array $items): void
    {
        // using `json_encode` here, since `UserSession` still uses PHP `serialize`
        $this->getBackendUser()->setAndSaveSessionData($sessionKey, json_encode($items, JSON_INVALID_UTF8_SUBSTITUTE));
    }

    protected function subjectMatchesItem(AccessSubjectInterface $subject, array $item): bool
    {
        // either group matches (if given), or subject matches
        return ($item['subject']['identity'] ?? null) === $subject->getIdentity()
            || ($subject->getGroup() !== null && ($item['subject']['group'] ?? null) === $subject->getGroup());
    }

    protected function getBackendUser(): BackendUserAuthentication
    {
        return $GLOBALS['BE_USER'];
    }
}
