<?php

namespace App\Services\Stripe;

use App\Services\Secrets\SecretVault;
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
    {
        return filled($this->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)
            ->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)
            ->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 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 = (string) $this->secret();

        if (blank($secret)) {
            throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
        }

        $request = Http::withToken($secret)->acceptJson()->timeout(20);

        // Stripe replays the original response for a repeated key instead of
        // creating a second object. That covers the gap this cannot close on
        // its own: a crash between Stripe creating a Price and us storing its
        // id. Their keys expire after 24 hours, so it protects a retry, not a
        // sync re-run next week — for which the stored ids are the guard.
        return $idempotencyKey !== null
            ? $request->withHeaders(['Idempotency-Key' => $idempotencyKey])
            : $request;
    }

    /**
     * The key in force: the one stored in the console if there is one, else the
     * environment.
     *
     * 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
    {
        return app(SecretVault::class)->get('stripe.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;
    }
}
