<?php

declare(strict_types=1);

namespace CpCreation\AltchaBundle\Http;

use CpCreation\AltchaBundle\Contract\AltchaServiceClientInterface;
use CpCreation\AltchaBundle\Exception\AltchaFailureReason;
use CpCreation\AltchaBundle\Exception\AltchaProtocolException;
use CpCreation\AltchaBundle\Exception\AltchaServiceRejectedException;
use CpCreation\AltchaBundle\Exception\AltchaUnavailableException;
use CpCreation\AltchaBundle\Model\Challenge;
use CpCreation\AltchaBundle\Model\ScopeManifest;
use CpCreation\AltchaBundle\Model\ScopePolicy;
use CpCreation\AltchaBundle\Model\ScopeSyncResult;
use CpCreation\AltchaBundle\Model\VerificationRequest;
use CpCreation\AltchaBundle\Model\VerificationResult;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class AltchaServiceClient implements AltchaServiceClientInterface
{
    private const MAX_RESPONSE_BYTES = 65_536;

    public function __construct(
        private HttpClientInterface $httpClient,
        private string $serviceUrl,
        private string $runtimeToken,
        private string $scopeSyncToken,
    ) {
    }

    public function resolvePolicy(string $scope, string $host): ScopePolicy
    {
        $data = $this->request('POST', '/v1/policies/resolve', [
            'host' => strtolower($host),
            'scopes' => [$scope],
        ], 'Bearer', $this->runtimeToken);

        $version = $data['policyVersion'] ?? null;
        $securityEpoch = $data['securityEpoch'] ?? null;
        $policies = $data['policies'] ?? null;
        if (!is_int($version) || !is_int($securityEpoch) || !is_array($policies)) {
            throw new AltchaProtocolException('The ALTCHA policy response is malformed.');
        }

        foreach ($policies as $policy) {
            if (!is_array($policy) || $scope !== ($policy['scope'] ?? null)) {
                continue;
            }

            return $this->mapPolicy($policy, $version, $securityEpoch);
        }

        throw new AltchaProtocolException(sprintf('The ALTCHA service omitted scope "%s".', $scope));
    }

    public function createChallenge(
        string $scope,
        string $host,
        string $clientIp,
        string $formToken,
        int $policyVersion,
    ): Challenge {
        $data = $this->request('POST', '/v1/challenges', [
            'host' => strtolower($host),
            'scope' => $scope,
            'clientIp' => $clientIp,
            'formToken' => $formToken,
            'policyVersion' => $policyVersion,
        ], 'Bearer', $this->runtimeToken);

        if (!is_array($data['parameters'] ?? null) || !is_string($data['signature'] ?? null)) {
            throw new AltchaProtocolException('The ALTCHA challenge response is malformed.');
        }

        return new Challenge($data['parameters'], $data['signature']);
    }

    public function verifyAndConsume(VerificationRequest $request): VerificationResult
    {
        $data = $this->request(
            'POST',
            '/v1/verifications',
            $request->toArray(),
            'Bearer',
            $this->runtimeToken,
        );

        if (!is_bool($data['verified'] ?? null) || !is_bool($data['consumed'] ?? null)) {
            throw new AltchaProtocolException('The ALTCHA verification response is malformed.');
        }

        return new VerificationResult($data['verified'], $data['consumed']);
    }

    public function synchronizeScopes(ScopeManifest $manifest, bool $dryRun): ScopeSyncResult
    {
        $data = $this->request('PUT', '/v1/scopes', [
            'source' => $manifest->source,
            'dryRun' => $dryRun,
            'scopes' => array_map(static fn ($scope): array => $scope->toArray(), $manifest->scopes),
        ], 'ScopeSync', $this->scopeSyncToken);

        if (
            !is_bool($data['dryRun'] ?? null)
            || !$this->isStringList($data['created'] ?? null)
            || !$this->isStringList($data['updated'] ?? null)
            || !$this->isStringList($data['unchanged'] ?? null)
        ) {
            throw new AltchaProtocolException('The ALTCHA scope synchronization response is malformed.');
        }

        return new ScopeSyncResult($data['dryRun'], $data['created'], $data['updated'], $data['unchanged']);
    }

    /** @param array<string, mixed> $policy */
    private function mapPolicy(array $policy, int $version, int $securityEpoch): ScopePolicy
    {
        $profile = $policy['profile'] ?? null;
        if (null !== $profile && !is_string($profile)) {
            throw new AltchaProtocolException('The ALTCHA policy profile is malformed.');
        }

        foreach (['enabled', 'altchaEnabled', 'subjectLimitsEnabled'] as $key) {
            if (!is_bool($policy[$key] ?? null)) {
                throw new AltchaProtocolException(sprintf('The ALTCHA policy field "%s" is malformed.', $key));
            }
        }
        if (!is_int($policy['minFillTimeMs'] ?? null) || !is_int($policy['maxEnvelopeAgeSeconds'] ?? null)) {
            throw new AltchaProtocolException('The ALTCHA timing policy is malformed.');
        }

        return new ScopePolicy(
            scope: (string) $policy['scope'],
            enabled: $policy['enabled'],
            version: $version,
            altchaEnabled: $policy['altchaEnabled'],
            profile: $profile,
            minimumFillTimeMs: $policy['minFillTimeMs'],
            maximumEnvelopeAgeSeconds: $policy['maxEnvelopeAgeSeconds'],
            subjectLimitsEnabled: $policy['subjectLimitsEnabled'],
            securityEpoch: $securityEpoch,
        );
    }

    /**
     * @param array<string, mixed> $payload
     *
     * @return array<string, mixed>
     */
    private function request(
        string $method,
        string $path,
        array $payload,
        string $authorizationScheme,
        string $token,
    ): array {
        if ('' === trim($this->serviceUrl) || '' === trim($token)) {
            throw new AltchaUnavailableException('The ALTCHA client is not configured.');
        }

        try {
            $response = $this->httpClient->request($method, rtrim($this->serviceUrl, '/').$path, [
                'headers' => [
                    'Accept' => 'application/json',
                    'Content-Type' => 'application/json',
                    'Authorization' => $authorizationScheme.' '.$token,
                ],
                'json' => $payload,
                'max_duration' => 5.0,
                'max_redirects' => 0,
                'timeout' => 3.0,
            ]);
            $status = $response->getStatusCode();
            $content = $response->getContent(false);
        } catch (TransportExceptionInterface $exception) {
            throw new AltchaUnavailableException(previous: $exception);
        }

        if (self::MAX_RESPONSE_BYTES < strlen($content)) {
            throw new AltchaProtocolException('The ALTCHA response is too large.');
        }
        if (200 > $status || 300 <= $status) {
            $retryAfterHeader = $response->getHeaders(false)['retry-after'][0] ?? null;
            $retryAfter = is_string($retryAfterHeader) && ctype_digit($retryAfterHeader)
                ? (int) $retryAfterHeader
                : null;
            throw new AltchaServiceRejectedException($this->failureReason($status, $content), $status, $retryAfter);
        }

        try {
            $data = json_decode($content, true, 32, JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new AltchaProtocolException('The ALTCHA response is not JSON.', previous: $exception);
        }

        if (!is_array($data)) {
            throw new AltchaProtocolException('The ALTCHA response is malformed.');
        }

        return $data;
    }

    private function isStringList(mixed $value): bool
    {
        return is_array($value)
            && array_is_list($value)
            && [] === array_filter($value, static fn ($item): bool => !is_string($item));
    }

    private function failureReason(int $status, string $content): AltchaFailureReason
    {
        $error = null;
        try {
            $decoded = json_decode($content, true, 8, JSON_THROW_ON_ERROR);
            $error = is_array($decoded) && is_string($decoded['error'] ?? null) ? $decoded['error'] : null;
        } catch (\JsonException) {
        }

        return match ($status) {
            409 => 'policy_changed' === $error
                ? AltchaFailureReason::POLICY_CHANGED
                : AltchaFailureReason::REPLAY,
            410 => AltchaFailureReason::EXPIRED,
            422 => AltchaFailureReason::INVALID_SOLUTION,
            429 => AltchaFailureReason::RATE_LIMITED,
            503 => AltchaFailureReason::UNAVAILABLE,
            403, 404 => AltchaFailureReason::FORBIDDEN_CONTEXT,
            default => AltchaFailureReason::UNAVAILABLE,
        };
    }
}
