<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Comment;

use Ferienpass\CoreBundle\Entity\ConversationItem;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Uid\Uuid;

/**
 * Signs and verifies the opaque token that ties an outbound comment email back
 * to its {@see ConversationItem} when the recipient replies. The token is embedded in a
 * plus-addressed reply address (e.g. `kommentar+<token>@reply.example.org`) that
 * Brevo Inbound Parse forwards to our webhook.
 *
 * Token = hex(comment uuid) "." HMAC-SHA256(hex uuid, secret)[0:16].
 * Verification is constant-time and fails closed.
 *
 * The token rides in an email local-part (`kommentar+<token>@…`), which MTAs and
 * Brevo's inbound parser routinely case-fold to lower case. So the encoding must
 * be case-insensitive: lower-case hex survives folding untouched, whereas the
 * previous base58 (mixed-case, case-significant) was silently corrupted, making
 * every inbound reply fail signature verification.
 */
final readonly class ReplyTokenSigner
{
    private const SIGNATURE_LENGTH = 16;

    public function __construct(
        #[Autowire(param: 'kernel.secret')]
        private string $secret,
        #[Autowire(param: 'ferienpass.inbound_domain')]
        private ?string $inboundDomain = null,
    ) {
    }

    public function sign(ConversationItem $comment): string
    {
        // 32 lower-case hex chars — case-fold-safe, and short enough that
        // `kommentar+<32hex>.<16hex>@…` stays inside the 64-char local-part limit.
        $payload = bin2hex($comment->getUuid()->toBinary());

        return $payload.'.'.$this->signature($payload);
    }

    /**
     * Returns the comment uuid encoded in a valid token, or null if the token is
     * malformed or the signature does not match.
     */
    public function verify(string $token): ?Uuid
    {
        // Normalise case: the address is routinely lower-cased in transit, and
        // our own hex tokens are lower-case, so this is a no-op for a genuine
        // token and defends against any stray upper-casing.
        $token = strtolower(trim($token));
        if (!str_contains($token, '.')) {
            return null;
        }

        [$payload, $signature] = explode('.', $token, 2);

        if (1 !== preg_match('/^[0-9a-f]{32}$/', $payload) || !hash_equals($this->signature($payload), $signature)) {
            return null;
        }

        try {
            return Uuid::fromBinary(hex2bin($payload));
        } catch (\Throwable) {
            return null;
        }
    }

    /**
     * The full reply-to address for a comment, or null when inbound replies are
     * not configured (no inbound domain).
     */
    public function replyAddress(ConversationItem $comment): ?string
    {
        if (null === $this->inboundDomain || '' === $this->inboundDomain) {
            return null;
        }

        return \sprintf('kommentar+%s@%s', $this->sign($comment), $this->inboundDomain);
    }

    private function signature(string $payload): string
    {
        return substr(hash_hmac('sha256', $payload, $this->secret), 0, self::SIGNATURE_LENGTH);
    }
}
