<?php

namespace App\Http\Controllers;

use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use App\Models\FailedCheckout;
use App\Support\StripeWebhookSecret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
 * Stripe webhook: verifies the signature (when a secret is configured) and turns
 * a paid checkout into a provisioning run. Idempotent via the order's
 * stripe_event_id — Stripe retries a webhook until it gets a 2xx.
 */
class StripeWebhookController extends Controller
{
    public function __invoke(
        Request $request,
        StartCustomerProvisioning $action,
        ApplyStripeBillingEvent $billing,
    ): JsonResponse {
        $payload = $request->getContent();
        $secret = StripeWebhookSecret::current();

        if (blank($secret)) {
            // Fail closed: an unconfigured secret must not authorize provisioning
            // outside local/testing.
            abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
        } elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
            abort(400, 'invalid signature');
        }

        $event = json_decode($payload, true) ?: [];
        $object = $event['data']['object'] ?? [];
        $type = $event['type'] ?? '';

        // The billing cycle is Stripe's: once a contract exists, they decide
        // when it renews, when a payment failed, and when it has ended. Handled
        // before the checkout branch because none of these are checkouts.
        $applied = $billing->dispatch($event);

        if ($applied !== false) {
            if ($applied === null) {
                // Either already applied, or about a contract that does not
                // exist yet — a checkout takes a moment to become one, and
                // Stripe does not deliver in order. Holding it costs a row and
                // saves a cancellation we would otherwise never hear about
                // again; a replay is a no-op if it was simply a duplicate.
                $billing->hold($event);
            }

            // 2xx either way. Stripe retrying would not change the answer, and
            // anything worth replaying is now held rather than dropped.
            return response()->json(['handled' => $type, 'applied' => $applied !== null]);
        }

        // Paid triggers: a synchronous checkout (completed + paid) OR an async
        // method clearing later (async_payment_succeeded). Ignore everything else,
        // including the still-unpaid completed event for async methods.
        // Eine Zahlung, die Tage später platzt. Bei SEPA oder
        // Sofortüberweisung klärt sie erst nach dem Abschluss; klärt sie
        // nicht, kommt genau dieses Ereignis — bis zum 31.7.2026 empfangen
        // und ignoriert. Dass daraus KEIN Auftrag entsteht, war richtig und
        // bleibt so. Falsch war, dass gar nichts entstand: ein verlorener
        // Verkauf, von dem niemand hört.
        if ($type === 'checkout.session.async_payment_failed') {
            return response()->json(['recorded' => $this->recordFailedCheckout($object)]);
        }

        $paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
            || $type === 'checkout.session.async_payment_succeeded';
        if (! $paid) {
            return response()->json(['ignored' => true]);
        }

        $meta = $object['metadata'] ?? [];

        // A real email is required — never merge unrelated customers under a
        // manufactured address or send credentials into the void.
        $email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
        if (blank($email)) {
            return response()->json(['ignored' => 'no_customer_email']);
        }

        $action->fromStripeEvent([
            // Deduplicate on the checkout session id (stable across the completed
            // and async-succeeded events for one purchase), not the event id.
            'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
            'email' => $email,
            'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
            'stripe_customer_id' => $object['customer'] ?? null,
            // The handle every later billing event arrives with. Without it an
            // invoice.paid cannot be matched to the contract it renews.
            'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
            // What a refund would have to be issued against, kept at the only
            // moment Stripe tells us. A subscription checkout charges through
            // an invoice and usually reports no PaymentIntent at all, so both
            // are taken and App\Actions\WithdrawContract uses whichever it has.
            'stripe_payment_intent_id' => is_string($object['payment_intent'] ?? null) ? $object['payment_intent'] : null,
            'stripe_invoice_id' => is_string($object['invoice'] ?? null) ? $object['invoice'] : null,
            // The consumer's express request that the service begin inside the
            // fourteen-day withdrawal window. Set on the session metadata by the
            // checkout; anything other than the exact string is a no, because a
            // consent that can be produced by a typo is not a consent.
            //
            // Nothing decides anything on it now — a withdrawal refunds the
            // whole amount either way — so it is carried as a record of what was
            // agreed rather than as a gate. See App\Models\Order.
            'immediate_start_consent' => ($meta['immediate_start'] ?? null) === '1',
            'plan' => $meta['plan'] ?? 'start',
            // What the customer was actually shown. Absent on a session created
            // before phase 5 put it there, and then the version on sale applies.
            'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
            'datacenter' => $meta['datacenter'] ?? 'fsn',
            'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
            // How much of that total was the one-off setup fee, which rode along
            // as a second line item on the session. From OUR metadata rather than
            // from Stripe's line items: this is the figure we asked for, we asked
            // for it once, and reading it back from the amounts would mean
            // guessing which line was which.
            //
            // Clamped to the total. A negative or oversized value could only come
            // from a session that was not ours, and the package's charge is derived
            // by subtracting this — a wild figure there would put a negative price
            // on the contract's own line.
            'setup_fee_cents' => max(0, min(
                (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
                (int) ($meta['setup_fee_cents'] ?? 0),
            )),
            'currency' => strtoupper($object['currency'] ?? 'eur'),
        ]);

        return response()->json(['ok' => true]);
    }

    /** Stripe's default replay tolerance, in seconds. */
    private const SIGNATURE_TOLERANCE = 300;

    /** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
    private function signatureValid(string $payload, string $header, string $secret): bool
    {
        if (blank($header)) {
            return false;
        }

        $timestamp = null;
        $signatures = [];
        foreach (explode(',', $header) as $segment) {
            [$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
            if ($key === 't') {
                $timestamp = $value;
            } elseif ($key === 'v1') {
                $signatures[] = $value; // keep every v1 (secret rotation sends several)
            }
        }

        if ($timestamp === null || $signatures === []) {
            return false;
        }

        // Reject replays outside the tolerance window.
        if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
            return false;
        }

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

        foreach ($signatures as $signature) {
            if (hash_equals($expected, $signature)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Den geplatzten Kauf festhalten — so viel, wie nötig ist, um jemanden
     * zurückzurufen, und keine Zeile mehr.
     *
     * Ohne E-Mail-Adresse gar nicht: eine Zeile, die niemanden benennt, ist
     * für den Betreiber nicht handhabbar und für den Kunden folgenlos.
     *
     * @param  array<string, mixed>  $object
     */
    private function recordFailedCheckout(array $object): bool
    {
        $email = $object['customer_details']['email'] ?? $object['customer_email'] ?? null;
        $sessionId = (string) ($object['id'] ?? '');

        if (blank($email) || $sessionId === '') {
            return false;
        }

        // firstOrCreate, weil Stripe einen Webhook wiederholt, bis er ein 2xx
        // bekommt — sonst stünde derselbe verlorene Verkauf dreimal da. Die
        // Eindeutigkeit steht zusätzlich als Index in der Tabelle.
        FailedCheckout::query()->firstOrCreate(
            ['stripe_session_id' => $sessionId],
            [
                'email' => (string) $email,
                'name' => $object['customer_details']['name'] ?? null,
                'plan' => $object['metadata']['plan'] ?? null,
                'amount_cents' => (int) ($object['amount_total'] ?? 0),
                'currency' => strtoupper((string) ($object['currency'] ?? 'eur')),
                'failed_at' => now(),
            ],
        );

        return true;
    }
}
