<?php

declare(strict_types=1);

namespace App\Services\Payments;

use App\Models\Payment;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use RuntimeException;

/**
 * Revolut Merchant API.
 *
 * Written against the HTTP API directly rather than a community SDK: this is
 * the payment path, and an unmaintained package that breaks when Revolut
 * versions an endpoint is a liability the studio cannot see coming.
 *
 * Money crosses this boundary in minor units — pence, as an integer. Revolut
 * expects that, and it also removes any chance of a float rounding £120.00
 * into £119.99 somewhere in transit.
 */
class RevolutGateway implements PaymentGateway
{
    public function __construct(
        private readonly HttpFactory $http,
    ) {}

    public function createCheckout(Payment $payment, string $description): CheckoutSession
    {
        // Idempotent by construction: if this payment already has an order,
        // hand back the existing one rather than creating a second. A
        // customer double-clicking "pay" must not produce two orders, only
        // one of which we are listening for.
        if ($payment->external_ref !== null && $payment->checkout_url !== null) {
            return new CheckoutSession(
                externalRef: $payment->external_ref,
                checkoutUrl: $payment->checkout_url,
                expiresAt: $payment->expires_at,
            );
        }

        $response = $this->request()->post('/orders', [
            'amount' => $this->toMinorUnits((string) $payment->amount),
            'currency' => $payment->currency,
            'description' => $description,
            // Our booking reference, echoed back on every webhook. It is how
            // we find the booking again without trusting anything else.
            'merchant_order_ext_ref' => $payment->reference_for_gateway(),
            'capture_mode' => 'automatic',
        ]);

        if ($response->failed()) {
            throw new RuntimeException(
                'Revolut rejected the order: '.$response->status().' '.$response->body()
            );
        }

        $body = $response->json();

        $checkoutUrl = $body['checkout_url'] ?? null;
        $orderId = $body['id'] ?? null;

        if (! is_string($orderId) || ! is_string($checkoutUrl)) {
            throw new RuntimeException(
                'Revolut returned an order without an id or checkout_url: '.$response->body()
            );
        }

        return new CheckoutSession(
            externalRef: $orderId,
            checkoutUrl: $checkoutUrl,
            expiresAt: isset($body['expires_at'])
                ? CarbonImmutable::parse($body['expires_at'])->setTimezone('UTC')
                : null,
            raw: is_array($body) ? $body : [],
        );
    }

    public function fetchStatus(string $externalRef): RemoteOrderStatus
    {
        $response = $this->request()->get("/orders/{$externalRef}");

        if ($response->failed()) {
            throw new RuntimeException(
                "Could not read Revolut order {$externalRef}: ".$response->status().' '.$response->body()
            );
        }

        $body = $response->json();
        $state = is_array($body) ? ($body['state'] ?? null) : null;

        return new RemoteOrderStatus(
            externalRef: $externalRef,
            isPaid: $this->stateMeansPaid($state),
            isFinished: $this->stateMeansFinished($state),
            providerState: is_string($state) ? $state : null,
            raw: is_array($body) ? $body : [],
        );
    }

    /**
     * Verify the signature Revolut puts on its callbacks.
     *
     * The signed payload is "v1.{timestamp}.{raw body}", HMAC-SHA256 with the
     * webhook signing secret. Three things matter here and each has bitten
     * someone before:
     *
     *  - the RAW body is signed, so this must never be handed a re-encoded
     *    array; whitespace and key order would differ and every request would
     *    fail to verify;
     *  - comparison is hash_equals, not ==, so a timing attack cannot walk
     *    the signature out of us a byte at a time;
     *  - the timestamp is checked independently, because a signature stays
     *    valid forever and a captured request could otherwise be replayed.
     *
     * @param  array<string, string>  $headers
     */
    public function verifyCallback(string $rawBody, array $headers): bool
    {
        $secret = (string) config('revolut.webhook_secret');

        if ($secret === '') {
            // Refuse rather than wave it through. An unset secret in
            // production would otherwise mean anyone can confirm a booking.
            return false;
        }

        $headers = array_change_key_case($headers, CASE_LOWER);

        $signatureHeader = $headers['revolut-signature'] ?? '';
        $timestamp = $headers['revolut-request-timestamp'] ?? '';

        if ($signatureHeader === '' || $timestamp === '') {
            return false;
        }

        if (! $this->timestampIsFresh($timestamp)) {
            return false;
        }

        $expected = hash_hmac('sha256', 'v1.'.$timestamp.'.'.$rawBody, $secret);

        // Revolut may send several signatures during a secret rotation,
        // comma separated. Any one of them matching is a valid request.
        foreach (explode(',', $signatureHeader) as $candidate) {
            $candidate = trim($candidate);

            if (str_starts_with($candidate, 'v1=')) {
                $candidate = substr($candidate, 3);
            }

            if ($candidate !== '' && hash_equals($expected, $candidate)) {
                return true;
            }
        }

        return false;
    }

    public function parseEvent(array $payload): GatewayEvent
    {
        $type = (string) ($payload['event'] ?? 'UNKNOWN');

        return new GatewayEvent(
            // Revolut does not always send a dedicated event id, so fall back
            // to a deterministic one. Two identical deliveries must produce
            // the same value or the idempotency guard cannot catch a retry.
            id: $this->eventId($payload, $type),
            type: $type,
            externalRef: isset($payload['order_id']) ? (string) $payload['order_id'] : null,
            isPaid: in_array($type, ['ORDER_COMPLETED', 'ORDER_AUTHORISED'], true),
            isFailure: in_array($type, [
                'ORDER_CANCELLED',
                'ORDER_PAYMENT_DECLINED',
                'ORDER_PAYMENT_FAILED',
                'ORDER_EXPIRED',
            ], true),
            payload: $payload,
        );
    }

    /** @param array<string, mixed> $payload */
    private function eventId(array $payload, string $type): string
    {
        foreach (['id', 'event_id'] as $key) {
            if (isset($payload[$key]) && is_scalar($payload[$key])) {
                return (string) $payload[$key];
            }
        }

        $orderId = (string) ($payload['order_id'] ?? 'unknown');

        return 'derived:'.$type.':'.$orderId;
    }

    private function timestampIsFresh(string $timestamp): bool
    {
        // Revolut sends milliseconds. Guard against a non-numeric header
        // rather than letting it cast to zero and look ancient.
        if (! is_numeric($timestamp)) {
            return false;
        }

        $sentAt = (int) round(((float) $timestamp) / 1000);
        $tolerance = (int) config('revolut.webhook_tolerance_seconds');

        return abs(CarbonImmutable::now('UTC')->getTimestamp() - $sentAt) <= $tolerance;
    }

    private function stateMeansPaid(mixed $state): bool
    {
        return in_array($state, ['COMPLETED', 'AUTHORISED', 'completed', 'authorised'], true);
    }

    private function stateMeansFinished(mixed $state): bool
    {
        return in_array($state, [
            'CANCELLED', 'FAILED', 'EXPIRED',
            'cancelled', 'failed', 'expired',
        ], true);
    }

    /** "120.00" becomes 12000. Never via float. */
    private function toMinorUnits(string $amount): int
    {
        return (int) bcmul($amount, '100', 0);
    }

    private function request(): PendingRequest
    {
        $mode = (string) config('revolut.mode');
        $baseUrl = config("revolut.base_urls.{$mode}");

        if (! is_string($baseUrl)) {
            throw new RuntimeException("Unknown Revolut mode [{$mode}].");
        }

        $secret = (string) config('revolut.secret_key');

        if ($secret === '') {
            throw new RuntimeException('REVOLUT_SECRET_KEY is not set.');
        }

        return $this->http
            ->baseUrl($baseUrl)
            ->withToken($secret)
            ->withHeaders([
                'Revolut-Api-Version' => (string) config('revolut.api_version'),
                'Accept' => 'application/json',
            ])
            ->timeout((int) config('revolut.timeout'))
            ->retry(2, 200, throw: false);
    }
}
