<?php

namespace App\Services\Stripe;

use App\Exceptions\StripeNotConfigured;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;

/**
 * Stripe's REST API over the HTTP client, rather than the SDK: this touches a
 * handful of endpoints, and a fake for the tests is easier to trust than a
 * mocked library. Not unit-tested against the real service (live I/O).
 */
class HttpStripeClient implements StripeClient
{
    public function isConfigured(): bool
    {
        // Reads the vault directly rather than through secret(): that method
        // now THROWS when the key is missing (see below), and every caller
        // here — the checkout controller, the catalogue sync commands — asks
        // this question to decide whether to proceed gracefully. Routing it
        // through secret() would turn "is it configured?" into an exception.
        return filled(app(SecretVault::class)->get('stripe.secret'));
    }

    public function createCheckoutSession(
        string $priceId,
        string $successUrl,
        string $cancelUrl,
        array $metadata = [],
        ?string $customerEmail = null,
        ?string $idempotencyKey = null,
        ?array $oneOff = null,
    ): string {
        return (string) $this->request($idempotencyKey)
            ->asForm()
            ->post($this->url('checkout/sessions'), array_filter([
                'mode' => 'subscription',
                'line_items[0][price]' => $priceId,
                'line_items[0][quantity]' => 1,
                ...$this->oneOffLine($oneOff),
                'success_url' => $successUrl,
                'cancel_url' => $cancelUrl,
                // Prefilled, not fixed: Stripe still lets the customer correct
                // it, and whatever they confirm is what customer_details.email
                // carries back — which is the address the credentials go to.
                'customer_email' => $customerEmail,
                ...$this->flatten('metadata', $metadata),
                // The same values on the subscription, because a renewal or a
                // payment failure months from now arrives with the subscription
                // and not with the session that opened it.
                ...$this->flatten('subscription_data[metadata]', $metadata),
            ], fn ($value) => $value !== null && $value !== ''))
            ->throw()
            ->json('url');
    }

    /**
     * The setup fee as a second, non-recurring line item.
     *
     * Priced inline (`price_data`) rather than against a stored Price id, and this
     * is the one place in the file that does. A plan Price is minted once and
     * billed against for years — grandfathering a customer needs the Price to
     * outlive the catalogue row, which is why those are stored. A setup fee is
     * taken once, at this checkout, and never referred to again: an immutable
     * Stripe Price per figure the operator ever types would be a table of dead
     * objects, and nothing would ever bill against yesterday's.
     *
     * No `recurring` key, which is what makes it one-off — Stripe charges it on
     * the initial invoice and on no later one.
     *
     * @param  array{amount_cents: int, label: string, currency: string}|null  $oneOff
     * @return array<string, string|int>
     */
    private function oneOffLine(?array $oneOff): array
    {
        $amount = (int) ($oneOff['amount_cents'] ?? 0);

        if ($oneOff === null || $amount <= 0) {
            return [];
        }

        return [
            'line_items[1][quantity]' => 1,
            'line_items[1][price_data][currency]' => strtolower((string) $oneOff['currency']),
            'line_items[1][price_data][unit_amount]' => $amount,
            'line_items[1][price_data][product_data][name]' => (string) $oneOff['label'],
        ];
    }

    public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
    {
        return (string) $this->request(IdempotencyKey::forProduct($idempotencyKey, $name, $metadata))
            ->asForm()
            ->post($this->url('products'), array_filter([
                'name' => $name,
                ...$this->flatten('metadata', $metadata),
            ]))
            ->throw()
            ->json('id');
    }

    public function createPrice(
        string $productId,
        int $amountCents,
        string $currency,
        string $interval,
        array $metadata = [],
        ?string $idempotencyKey = null,
    ): string {
        return (string) $this->request(IdempotencyKey::forPrice(
            $idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata,
        ))
            ->asForm()
            ->post($this->url('prices'), [
                'product' => $productId,
                'unit_amount' => $amountCents,
                'currency' => strtolower($currency),
                // Recurring, because a plan is a subscription. The interval
                // belongs to the Price, which is why monthly and yearly cannot
                // share one.
                'recurring[interval]' => $interval,
                ...$this->flatten('metadata', $metadata),
            ])
            ->throw()
            ->json('id');
    }

    public function archivePrice(string $priceId): void
    {
        // Deactivated, never deleted: a Stripe Price is immutable and existing
        // subscriptions keep billing against it. That is the same
        // grandfathering our own catalogue does, enforced on their side too.
        $this->request()
            ->asForm()
            ->post($this->url('prices/'.$priceId), ['active' => 'false'])
            ->throw();
    }

    public function activatePrice(string $priceId): void
    {
        // `active` is one of the very few fields a Stripe Price does let you
        // change, which is what makes bringing one back possible at all — the
        // amount is not, and that is why a changed figure is always a new Price.
        $this->request()
            ->asForm()
            ->post($this->url('prices/'.$priceId), ['active' => 'true'])
            ->throw();
    }

    public function activePricesFor(string $productId): array
    {
        $prices = [];
        $after = null;

        // Paged through to the end, for the same reason invoiceLines() is: Stripe
        // caps a page at a hundred, and a family Product collects a Price per
        // version, term, treatment and rate change. Stopping at the first page
        // would leave an orphan unfound and mint the duplicate anyway.
        do {
            $page = $this->request()
                ->get($this->url('prices'), array_filter([
                    'product' => $productId,
                    'active' => 'true',
                    'type' => 'recurring',
                    'limit' => 100,
                    'starting_after' => $after,
                ], fn ($value) => $value !== null))
                ->throw()
                ->json();

            $data = (array) ($page['data'] ?? []);

            foreach ($data as $price) {
                $recurring = (array) ($price['recurring'] ?? []);

                $prices[] = [
                    'id' => (string) ($price['id'] ?? ''),
                    'unit_amount' => (int) ($price['unit_amount'] ?? 0),
                    'currency' => strtoupper((string) ($price['currency'] ?? '')),
                    'interval' => (string) ($recurring['interval'] ?? ''),
                    // The four properties that decide what a Price CHARGES
                    // beyond its amount. createPrice() sends none of them, so
                    // Stripe defaults every one — and an ABSENT key is that
                    // default, never a reason to reject. Reported rather than
                    // filtered: the class that promises adoption cannot move
                    // money is AdoptStripePrice, and it could not keep that
                    // promise while these lived here, where FakeStripeClient
                    // cannot express them and no test of that class could reach
                    // them.
                    'interval_count' => (int) ($recurring['interval_count'] ?? 1),
                    'usage_type' => (string) ($recurring['usage_type'] ?? 'licensed'),
                    'transform_quantity' => (array) ($price['transform_quantity'] ?? []) !== [],
                    'billing_scheme' => (string) ($price['billing_scheme'] ?? 'per_unit'),
                    'created' => (int) ($price['created'] ?? 0),
                    'metadata' => array_map(
                        fn ($value) => (string) $value,
                        (array) ($price['metadata'] ?? []),
                    ),
                ];
            }

            $after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);

            // invoiceLines() stops here, and there that costs a short document.
            // Here it costs an unseen orphan and a second live Price for one
            // figure — the incident. So it is an error, not a stopping point.
            if (($page['has_more'] ?? false) === true && $after === null) {
                throw new RuntimeException(
                    'Stripe reported another page of prices and the last item of this one carried no id; '
                    .'refusing to stop half-read.'
                );
            }
        } while (($page['has_more'] ?? false) === true);

        return $prices;
    }

    public function activeProducts(): array
    {
        $products = [];
        $after = null;

        do {
            $page = $this->request()
                ->get($this->url('products'), array_filter([
                    'active' => 'true',
                    'limit' => 100,
                    'starting_after' => $after,
                ], fn ($value) => $value !== null))
                ->throw()
                ->json();

            $data = (array) ($page['data'] ?? []);

            foreach ($data as $product) {
                $products[] = [
                    'id' => (string) ($product['id'] ?? ''),
                    'name' => (string) ($product['name'] ?? ''),
                    'created' => (int) ($product['created'] ?? 0),
                    'metadata' => array_map(
                        fn ($value) => (string) $value,
                        (array) ($product['metadata'] ?? []),
                    ),
                ];
            }

            $after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);

            // Same reason as activePricesFor(): a half-read list here means a
            // Product we own goes unrecognised, and the next run mints a second
            // one — which blinds the Price recognition for the whole family.
            if (($page['has_more'] ?? false) === true && $after === null) {
                throw new RuntimeException(
                    'Stripe reported another page of products and the last item of this one carried no id; '
                    .'refusing to stop half-read.'
                );
            }
        } while (($page['has_more'] ?? false) === true);

        return $products;
    }

    public function updatePriceMetadata(string $priceId, array $metadata): void
    {
        $this->request()
            ->asForm()
            ->post($this->url('prices/'.$priceId), $this->flatten('metadata', $metadata))
            ->throw();
    }

    public function updateSubscriptionPrice(
        string $subscriptionId,
        string $itemId,
        string $priceId,
        string $prorationBehaviour,
    ): void {
        // The item is named as well as the price. Without its id Stripe adds a
        // SECOND item rather than replacing the first, and the customer is then
        // billed for both packages at once.
        $this->request()
            ->asForm()
            ->post($this->url('subscriptions/'.$subscriptionId), [
                'items[0][id]' => $itemId,
                'items[0][price]' => $priceId,
                'proration_behavior' => $prorationBehaviour,
            ])
            ->throw();
    }

    public function subscriptionItemId(string $subscriptionId): ?string
    {
        $id = $this->request()
            ->get($this->url('subscriptions/'.$subscriptionId))
            ->throw()
            ->json('items.data.0.id');

        return is_string($id) && $id !== '' ? $id : null;
    }

    public function addSubscriptionItem(
        string $subscriptionId,
        string $priceId,
        int $quantity,
        string $prorationBehaviour,
        ?string $idempotencyKey = null,
    ): string {
        // Straight onto the item collection rather than through the
        // subscription: posting items[] to the subscription REPLACES the set,
        // so an item added that way takes the package's off with it.
        return (string) $this->request($idempotencyKey)
            ->asForm()
            ->post($this->url('subscription_items'), [
                'subscription' => $subscriptionId,
                'price' => $priceId,
                'quantity' => $quantity,
                'proration_behavior' => $prorationBehaviour,
            ])
            ->throw()
            ->json('id');
    }

    public function updateSubscriptionItemQuantity(
        string $itemId,
        int $quantity,
        string $prorationBehaviour,
    ): void {
        $this->request()
            ->asForm()
            ->post($this->url('subscription_items/'.$itemId), [
                'quantity' => $quantity,
                'proration_behavior' => $prorationBehaviour,
            ])
            ->throw();
    }

    public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void
    {
        $this->request()
            ->asForm()
            ->delete($this->url('subscription_items/'.$itemId), [
                'proration_behavior' => $prorationBehaviour,
            ])
            ->throw();
    }

    public function cancelSubscription(
        string $subscriptionId,
        string $when,
        ?string $idempotencyKey = null,
    ): void {
        $atPeriodEnd = match ($when) {
            self::CANCEL_AT_PERIOD_END => true,
            self::CANCEL_IMMEDIATELY => false,
            // A typo would take a paying customer's service away this afternoon,
            // or leave a withdrawn one subscribed for ever. Neither is a thing to
            // fall back to a default about.
            default => throw new RuntimeException("Unknown cancellation timing: {$when}"),
        };

        if ($atPeriodEnd) {
            // An UPDATE and not a delete. The subscription stays alive to the
            // boundary the customer has paid to, raises no further cycle, and
            // Stripe ends it there of its own accord; deleting it here would take
            // the service away the moment somebody clicked "cancel".
            $this->request($idempotencyKey)
                ->asForm()
                ->post($this->url('subscriptions/'.$subscriptionId), ['cancel_at_period_end' => 'true'])
                ->throw();

            return;
        }

        // Both stated rather than left to Stripe's defaults, because both decide
        // money. `prorate` would raise a credit for the unused days and
        // `invoice_now` would charge whatever is unbilled — and the caller that
        // asks for an immediate stop is a withdrawal, which sends the WHOLE
        // amount back from a cancellation document of its own. A credit note of
        // Stripe's beside that would be the same money twice.
        $this->request($idempotencyKey)
            ->asForm()
            ->delete($this->url('subscriptions/'.$subscriptionId), [
                'prorate' => 'false',
                'invoice_now' => 'false',
            ])
            ->throw();
    }

    public function refund(
        string $paymentReference,
        ?int $amountCents = null,
        ?string $idempotencyKey = null,
    ): string {
        // Stripe takes the payment under one key or the other, never both, and
        // the id says which it is. Guessing wrong is a 400, which is at least
        // loud — but sending a charge id as a payment intent would refund
        // nothing on some API versions and everything on others.
        $key = str_starts_with($paymentReference, 'ch_') ? 'charge' : 'payment_intent';

        return (string) $this->request($idempotencyKey)
            ->asForm()
            ->post($this->url('refunds'), array_filter([
                $key => $paymentReference,
                // Omitted for a full refund, which is what Stripe's own absence
                // of an amount means. Passing the full figure explicitly would
                // be the same thing with one more chance to be wrong.
                'amount' => $amountCents,
                // The only reason we ever have. A withdrawal is the customer
                // asking for their money back, and Stripe's dispute analytics
                // read this field.
                'reason' => 'requested_by_customer',
            ], fn ($value) => $value !== null))
            ->throw()
            ->json('id');
    }

    public function invoicePaymentReference(string $invoiceId): ?string
    {
        $invoice = $this->request()
            ->get($this->url('invoices/'.$invoiceId))
            ->throw()
            ->json();

        // Both fields, in this order. Which one an invoice carries depends on
        // the API version the account is pinned to — newer ones dropped
        // `payment_intent` from the invoice — and reading only one of them
        // would make refunds work on some installations and not others.
        foreach (['payment_intent', 'charge'] as $field) {
            $value = $invoice[$field] ?? null;

            if (is_string($value) && $value !== '') {
                return $value;
            }
        }

        return null;
    }

    public function invoiceLines(string $invoiceId): array
    {
        $lines = [];
        $after = null;

        // Paged through to the end. Stripe caps a page at a hundred, and the
        // point of asking at all is to get the lines the event did not carry —
        // stopping at the first page would reintroduce exactly that gap.
        do {
            $page = $this->request()
                ->get($this->url('invoices/'.$invoiceId.'/lines'), array_filter([
                    'limit' => 100,
                    'starting_after' => $after,
                ]))
                ->throw()
                ->json();

            $data = (array) ($page['data'] ?? []);
            $lines = [...$lines, ...$data];
            $after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);
        } while (($page['has_more'] ?? false) === true && $after !== null);

        return $lines;
    }

    private function request(?string $idempotencyKey = null): PendingRequest
    {
        // secret() itself throws when the key is missing, so there is nothing
        // blank left to check here — see its docblock.
        $request = Http::withToken($this->secret())->acceptJson()->timeout(20);

        // Stripe replays the original response for a repeated key instead of
        // creating a second object — but ONLY for a call repeated exactly; a
        // changed parameter under a used key is HTTP 400 for twenty-four hours.
        // The catalogue calls therefore fold a fingerprint of their parameters
        // into the key (see IdempotencyKey); the money calls deliberately do
        // not. Their keys expire after 24 hours, so this protects a retry, not
        // a sync re-run next week — for which the stored ids and
        // AdoptStripePrice are the guard.
        return $idempotencyKey !== null
            ? $request->withHeaders(['Idempotency-Key' => $idempotencyKey])
            : $request;
    }

    /**
     * The key in force: the one stored in the console for the active operating
     * mode. Throws rather than returning blank — see StripeNotConfigured.
     *
     * Resolved HERE, at the point of use, rather than overlaid onto config at
     * boot — an overlay would add a query to every request including the public
     * site, and a queue worker would keep whatever was true when it started.
     */
    private function secret(): string
    {
        $secret = app(SecretVault::class)->get('stripe.secret');

        // Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
        // und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
        // zuordnet. Hier abzubrechen sagt, was fehlt.
        if (blank($secret)) {
            throw StripeNotConfigured::forMode(OperatingMode::current()->value);
        }

        return $secret;
    }

    private function url(string $path): string
    {
        return rtrim((string) config('services.stripe.api_base'), '/').'/'.$path;
    }

    /**
     * Stripe's form API takes nested keys as `metadata[key]`.
     *
     * @param  array<string, string|int|null>  $values
     * @return array<string, string>
     */
    private function flatten(string $prefix, array $values): array
    {
        $flat = [];

        foreach ($values as $key => $value) {
            if ($value !== null && $value !== '') {
                $flat["{$prefix}[{$key}]"] = (string) $value;
            }
        }

        return $flat;
    }

    public function createSetupIntent(string $customerId): array
    {
        $intent = $this->request()
            ->asForm()
            ->post($this->url('setup_intents'), [
                'customer' => $customerId,
                'usage' => 'off_session',
            ])
            ->throw()
            ->json();

        return [
            'id' => (string) $intent['id'],
            'client_secret' => (string) $intent['client_secret'],
        ];
    }

    public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void
    {
        $this->request()
            ->asForm()
            ->post($this->url('customers/'.$customerId), [
                'invoice_settings' => ['default_payment_method' => $paymentMethodId],
            ])
            ->throw();
    }

    public function defaultPaymentMethod(string $customerId): ?array
    {
        $customer = $this->request()
            ->get($this->url('customers/'.$customerId), [
                // Ausgeklappt, sonst kommt nur die ID zurück und die Seite
                // bräuchte einen zweiten Aufruf für vier Ziffern.
                'expand' => ['invoice_settings.default_payment_method'],
            ])
            ->throw()
            ->json();

        $method = $customer['invoice_settings']['default_payment_method'] ?? null;

        // Ein Kunde, der über eine Rechnung bezahlt hat, hat keine — das ist
        // der Normalfall und kein halbes Array wert.
        if (! is_array($method) || ! isset($method['card'])) {
            return null;
        }

        return [
            'id' => (string) $method['id'],
            'brand' => (string) $method['card']['brand'],
            'last4' => (string) $method['card']['last4'],
            'exp_month' => (int) $method['card']['exp_month'],
            'exp_year' => (int) $method['card']['exp_year'],
        ];
    }

    public function openInvoices(string $customerId): array
    {
        $invoices = [];
        $after = null;

        // Bis zum Ende geblättert, wie invoiceLines() darunter. Stripe deckelt
        // eine Seite bei hundert, und dieses Projekt hat die abgeschnittene
        // erste Seite schon einmal bezahlt: der alte Hetzner-Client fand einen
        // Eintrag jenseits der hundert nicht und legte einen zweiten an. Hier
        // ginge es ums Geld — eine ältere offene Rechnung auf Seite zwei wäre
        // für den Kunden nicht bezahlbar, weil sie ihm nie angezeigt wird.
        // (Codex-Review 2026-07-31, P2.)
        do {
            $page = $this->request()
                ->get($this->url('invoices'), array_filter([
                    'customer' => $customerId,
                    // Finalisiert und unbezahlt. Ohne den Filter käme die ganze
                    // Historie zurück, und neben längst beglichenen Rechnungen
                    // stünde ein Bezahlknopf.
                    'status' => 'open',
                    'limit' => 100,
                    'starting_after' => $after,
                ]))
                ->throw()
                ->json();

            $data = $page['data'] ?? [];
            $invoices = array_merge($invoices, $data);
            $after = $data === [] ? null : (string) end($data)['id'];
        } while (($page['has_more'] ?? false) && $after !== null);

        return array_map(fn (array $invoice) => [
            'id' => (string) $invoice['id'],
            'number' => isset($invoice['number']) ? (string) $invoice['number'] : null,
            'amount_due_cents' => (int) ($invoice['amount_due'] ?? 0),
            'currency' => strtoupper((string) ($invoice['currency'] ?? 'eur')),
            'created_at' => (string) ($invoice['created'] ?? ''),
        ], $invoices);
    }

    public function payInvoice(string $invoiceId): array
    {
        $response = $this->request()->asForm()->post($this->url('invoices/'.$invoiceId.'/pay'));

        if ($response->successful()) {
            return [
                'paid' => (bool) $response->json('paid', false),
                'status' => (string) $response->json('status', 'unknown'),
                'failure' => null,
            ];
        }

        // 402 ist Stripes Antwort auf eine abgelehnte Karte — eine Aussage
        // über das Zahlungsmittel. Alles andere (401, 5xx) sagt etwas über
        // UNS und bleibt ein Wurf: es als „Ihre Karte wurde abgelehnt"
        // auszugeben wäre eine Behauptung über das Konto des Kunden,
        // hergeleitet aus unserem eigenen Fehler.
        if ($response->status() !== 402) {
            $response->throw();
        }

        return [
            'paid' => false,
            'status' => 'open',
            'failure' => (string) $response->json('error.message', ''),
        ];
    }

    public function createFeeInvoice(
        string $customerId,
        int $amountCents,
        string $currency,
        string $description,
        ?string $idempotencyKey = null,
    ): string {
        // Ein BARER Schlüssel, kein Fingerabdruck der Parameter — dieselbe
        // Wahl wie bei refund() und cancelSubscription(): hier ist ein zweites
        // Objekt das Geld eines Kunden ein zweites Mal, und Stripes Weigerung
        // ist dann die richtige Antwort. Siehe IdempotencyKey.
        $this->request($idempotencyKey)
            ->asForm()
            ->post($this->url('invoiceitems'), [
                'customer' => $customerId,
                'amount' => $amountCents,
                'currency' => strtolower($currency),
                'description' => $description,
            ])
            ->throw();

        $invoice = $this->request($idempotencyKey !== null ? $idempotencyKey.'-invoice' : null)
            ->asForm()
            ->post($this->url('invoices'), [
                'customer' => $customerId,
                'collection_method' => 'charge_automatically',
                // Ohne das bliebe die Rechnung ein ENTWURF, und niemand zöge
                // sie je ein — eine Mahngebühr, die nur in Stripe herumliegt.
                'auto_advance' => 'true',
                'description' => $description,
            ])
            ->throw()
            ->json();

        return (string) $invoice['id'];
    }
}
