<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Rest\Service;

use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\ApiKeyConflictException;
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;

use function sprintf;

readonly class ApiKeyService implements ApiKeyServiceInterface
{
    public function __construct(private EntityManagerInterface $em, private ApiKeyRepositoryInterface $repo)
    {
    }

    /**
     * @inheritDoc
     */
    public function create(ApiKeyMeta $apiKeyMeta): ApiKey
    {
        return $this->em->wrapInTransaction(function () use ($apiKeyMeta) {
            $apiKey = ApiKey::fromMeta($this->ensureUniqueName($apiKeyMeta));
            $this->em->persist($apiKey);

            return $apiKey;
        });
    }

    /**
     * Given an ApiKeyMeta object, it returns another instance ensuring the name is unique.
     * - If the name was auto-generated, it continues re-trying until a unique name is resolved.
     * - If the name was explicitly provided, it throws in case of name conflict.
     */
    private function ensureUniqueName(ApiKeyMeta $apiKeyMeta): ApiKeyMeta
    {
        if (! $this->repo->nameExists($apiKeyMeta->name)) {
            return $apiKeyMeta;
        }

        if (! $apiKeyMeta->isNameAutoGenerated) {
            throw new InvalidArgumentException(
                sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name),
            );
        }

        return $this->ensureUniqueName(ApiKeyMeta::fromParams(
            expirationDate: $apiKeyMeta->expirationDate,
            roleDefinitions: $apiKeyMeta->roleDefinitions,
        ));
    }

    public function createInitial(string $key): ApiKey|null
    {
        return $this->repo->createInitialApiKey($key);
    }

    public function check(string $key): ApiKeyCheckResult
    {
        $apiKey = $this->findByKey($key);
        return new ApiKeyCheckResult($apiKey);
    }

    /**
     * @inheritDoc
     */
    public function deleteByName(string $apiKeyName): void
    {
        $affectedResults = $this->repo->deleteByName($apiKeyName);
        if ($affectedResults === 0) {
            throw ApiKeyNotFoundException::forName($apiKeyName);
        }
    }

    /**
     * @inheritDoc
     */
    public function disableByName(string $apiKeyName): ApiKey
    {
        $apiKey = $this->repo->findOneBy(['name' => $apiKeyName]);
        if ($apiKey === null) {
            throw ApiKeyNotFoundException::forName($apiKeyName);
        }

        return $this->disableApiKey($apiKey);
    }

    /**
     * @inheritDoc
     */
    public function disableByKey(string $key): ApiKey
    {
        $apiKey = $this->findByKey($key);
        if ($apiKey === null) {
            throw ApiKeyNotFoundException::forKey($key);
        }

        return $this->disableApiKey($apiKey);
    }

    private function disableApiKey(ApiKey $apiKey): ApiKey
    {
        $apiKey->disable();
        $this->em->flush();

        return $apiKey;
    }

    /**
     * @return ApiKey[]
     */
    public function listKeys(bool $enabledOnly = false): array
    {
        $conditions = $enabledOnly ? ['enabled' => true] : [];
        return $this->repo->findBy($conditions);
    }

    /**
     * @inheritDoc
     */
    public function renameApiKey(Renaming $apiKeyRenaming): ApiKey
    {
        $apiKey = $this->repo->findOneBy(['name' => $apiKeyRenaming->oldName]);
        if ($apiKey === null) {
            throw ApiKeyNotFoundException::forName($apiKeyRenaming->oldName);
        }

        if (! $apiKeyRenaming->nameChanged()) {
            return $apiKey;
        }

        $this->em->wrapInTransaction(function () use ($apiKeyRenaming, $apiKey): void {
            if ($this->repo->nameExists($apiKeyRenaming->newName)) {
                throw ApiKeyConflictException::forName($apiKeyRenaming->newName);
            }

            $apiKey->name = $apiKeyRenaming->newName;
        });

        return $apiKey;
    }

    private function findByKey(string $key): ApiKey|null
    {
        return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]);
    }
}
