<?php

namespace App\Services\Stripe;

use RuntimeException;

/**
 * A Stripe that records what it was asked to do.
 *
 * The catalogue sync has to be exercised for real — it is the thing that must
 * be idempotent and must never mint a second Price for the same row — and that
 * is testable without a network. Ids are deterministic so a test can assert
 * which product a price was attached to.
 */
class FakeStripeClient implements StripeClient
{
    public bool $configured = true;

    /** @var array<int, array{name: string, metadata: array, created: int}> */
    public array $products = [];

    /** @var array<int, array{product: string, amount: int, currency: string, interval: string, interval_count: int, usage_type: string, transform_quantity: bool, billing_scheme: string, metadata: array, created: int}> */
    public array $prices = [];

    /** @var array<int, string> */
    public array $archived = [];

    /**
     * Every Price that was put back on sale, in order — a rate that moved and
     * moved back reaches for the Price it already has rather than minting a
     * second one for the same amount.
     *
     * @var array<int, string>
     */
    public array $activated = [];

    /**
     * Every metadata write, in order, so a test can assert that an adopted
     * orphan was brought up to today's metadata instead of being replaced.
     *
     * @var array<int, array{price: string, metadata: array<string, string>}>
     */
    public array $metadataUpdates = [];

    /**
     * Idempotency key → what was first answered for it, and a fingerprint of
     * what it was first sent with.
     *
     * The fingerprint is the half that was missing. This ledger replayed the
     * first id for a repeated key without ever looking at what the second call
     * was asking for — so it was unrealistic in precisely the way that mattered,
     * and no test could see the 2026-07-29 blockade.
     *
     * @var array<string, array{id: string, fingerprint: string}>
     */
    public array $keys = [];

    /**
     * What Stripe would answer when asked which item a subscription bills
     * through: subscription id → item id.
     *
     * @var array<string, string>
     */
    public array $items = [];

    /**
     * Every price swap that was asked for, in order, so a test can assert the
     * proration behaviour as well as the price.
     *
     * @var array<int, array{subscription: string, item: string, price: string, proration: string}>
     */
    public array $priceChanges = [];

    /**
     * The items on each subscription as this Stripe sees them: item id → what
     * it bills. Modules are added and removed here, so a test can assert how
     * many items a contract carries and at which quantity.
     *
     * @var array<string, array{subscription: string, price: string, quantity: int}>
     */
    public array $subscriptionItems = [];

    /**
     * Every item call that was made, in order, so a test can assert the
     * proration behaviour as well as the outcome.
     *
     * @var array<int, array<string, mixed>>
     */
    public array $itemCalls = [];

    /**
     * The lines this Stripe would report for an invoice: invoice id → lines.
     * Only for the invoice whose lines an event did not carry in full.
     *
     * @var array<string, array<int, array<string, mixed>>>
     */
    public array $invoiceLines = [];

    /**
     * Every order to stop billing, in order, so a test can assert WHEN a
     * contract was cancelled as well as that it was — the difference between a
     * customer keeping the term they paid for and losing it.
     *
     * @var array<int, array{subscription: string, when: string, key: ?string}>
     */
    public array $cancellations = [];

    /**
     * Every refund that was sent, in order, so a test can assert the amount as
     * well as the fact of it.
     *
     * @var array<int, array{payment: string, amount: ?int, key: ?string}>
     */
    public array $refunds = [];

    /**
     * What this Stripe would answer when asked which payment an invoice was
     * settled by: invoice id → payment reference.
     *
     * @var array<string, ?string>
     */
    public array $invoicePayments = [];

    /**
     * Set to make every call fail, the way an outage, a revoked key or a
     * network without a route out does.
     */
    public ?string $failWith = null;

    /**
     * Every checkout that was opened, in order.
     *
     * `one_off` is the setup-fee line, or null where none was sent — a test has to
     * be able to assert both that it is charged and that it is absent when the
     * operator has set no fee.
     *
     * @var array<int, array{price: string, success: string, cancel: string, metadata: array, email: ?string, one_off: ?array<string, mixed>}>
     */
    public array $checkouts = [];

    public function isConfigured(): bool
    {
        return $this->configured;
    }

    public function createCheckoutSession(
        string $priceId,
        string $successUrl,
        string $cancelUrl,
        array $metadata = [],
        ?string $customerEmail = null,
        ?string $idempotencyKey = null,
        ?array $oneOff = null,
    ): string {
        $this->failIfAsked();

        $this->checkouts[] = [
            'price' => $priceId,
            'success' => $successUrl,
            'cancel' => $cancelUrl,
            'metadata' => $metadata,
            'email' => $customerEmail,
            'one_off' => $oneOff,
        ];

        return 'https://checkout.stripe.test/session/'.count($this->checkouts);
    }

    public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
    {
        $key = IdempotencyKey::forProduct($idempotencyKey, $name, $metadata);

        // The same parameters forProduct() just fingerprinted, asked for
        // through the one method that knows what they are — a local literal
        // here could drift from what the key actually covers without a test
        // ever seeing it.
        $parameters = IdempotencyKey::productParameters($name, $metadata);

        // Replays the first answer for a repeated key, as Stripe does.
        $replayed = $this->replay($key, $parameters);

        if ($replayed !== null) {
            return $replayed;
        }

        $id = 'prod_'.substr(sha1($name.count($this->products)), 0, 12);
        $this->products[$id] = [
            'name' => $name,
            'metadata' => $metadata,
            // Stripe stamps a creation time and AdoptStripeProduct takes the
            // OLDEST of several. A counter cannot drift the way a clock in a
            // test can.
            'created' => count($this->products) + 1,
        ];

        $this->rememberKey($key, $id, $parameters);

        return $id;
    }

    public function createPrice(
        string $productId,
        int $amountCents,
        string $currency,
        string $interval,
        array $metadata = [],
        ?string $idempotencyKey = null,
    ): string {
        $key = IdempotencyKey::forPrice($idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata);

        // Same reasoning as createProduct(): the parameters forPrice() just
        // fingerprinted, not a second literal that has to be kept in step by
        // hand.
        $parameters = IdempotencyKey::priceParameters($productId, $amountCents, $currency, $interval, $metadata);

        $replayed = $this->replay($key, $parameters);

        if ($replayed !== null) {
            return $replayed;
        }

        $id = 'price_'.substr(sha1($productId.$amountCents.$interval.count($this->prices)), 0, 12);
        $this->prices[$id] = [
            'product' => $productId,
            'amount' => $amountCents,
            'currency' => $currency,
            'interval' => $interval,
            // Stripe's defaults, and the only thing createPrice() can produce:
            // it sends none of these four, so every Price this platform mints
            // carries exactly them.
            'interval_count' => 1,
            'usage_type' => 'licensed',
            'transform_quantity' => false,
            'billing_scheme' => 'per_unit',
            'metadata' => $metadata,
            // Stripe stamps every object with its creation time and
            // AdoptStripePrice takes the OLDEST of several orphans. Counted
            // rather than clocked, because two Prices minted in the same second
            // would share a timestamp — but the two counters are independent:
            // plantPrice() stamps `created: 1` of its own accord, which ties with
            // a Price minted here while this list was still empty. A tie is
            // possible, not merely theoretical — which is why AdoptStripePrice's
            // usort breaks one by price id rather than leaving it to Stripe's
            // list order, and a test can plant two prices at the same `created`
            // on purpose to prove it.
            'created' => count($this->prices) + 1,
        ];

        $this->rememberKey($key, $id, $parameters);

        return $id;
    }

    public function archivePrice(string $priceId): void
    {
        $this->archived[] = $priceId;
    }

    public function activatePrice(string $priceId): void
    {
        // Taken off the archived list rather than merely appended to a second
        // one, so a test asking "is this Price still being sold?" gets the
        // answer Stripe would give instead of a history it has to interpret.
        $this->archived = array_values(array_filter(
            $this->archived,
            fn (string $id) => $id !== $priceId,
        ));

        $this->activated[] = $priceId;
    }

    public function activePricesFor(string $productId): array
    {
        // No failIfAsked(): createPrice(), the call this one stands in front of,
        // does not fail either, and a test that scripts an outage for a refund
        // must not have its catalogue sync change behaviour underneath it.
        $found = [];

        foreach ($this->prices as $id => $price) {
            if ($price['product'] !== $productId || in_array($id, $this->archived, true)) {
                continue;
            }

            $found[] = [
                'id' => $id,
                'unit_amount' => $price['amount'],
                'currency' => strtoupper($price['currency']),
                'interval' => $price['interval'],
                'interval_count' => $price['interval_count'],
                'usage_type' => $price['usage_type'],
                'transform_quantity' => $price['transform_quantity'],
                'billing_scheme' => $price['billing_scheme'],
                'created' => $price['created'],
                // Cast like the wire does: Stripe hands metadata back as
                // strings, and an un-cast int here would let a test pass where
                // production's strict === fails.
                'metadata' => array_map(fn ($value) => (string) $value, $price['metadata']),
            ];
        }

        return $found;
    }

    public function activeProducts(): array
    {
        $found = [];

        foreach ($this->products as $id => $product) {
            $found[] = [
                'id' => $id,
                'name' => $product['name'],
                'created' => $product['created'],
                'metadata' => array_map(fn ($value) => (string) $value, $product['metadata']),
            ];
        }

        return $found;
    }

    /**
     * A Product that exists at Stripe and in no row of ours — the orphan a run
     * leaves when it dies between Stripe's create and our storing its id.
     */
    public function plantProduct(string $id, string $name, array $metadata = [], int $created = 1): void
    {
        $this->products[$id] = ['name' => $name, 'metadata' => $metadata, 'created' => $created];
    }

    public function updatePriceMetadata(string $priceId, array $metadata): void
    {
        if (isset($this->prices[$priceId])) {
            // Merged, not replaced — Stripe leaves a key you do not send where
            // it is. A fake that replaced would let a test prove the opposite of
            // production, and "write only when it differs" in AdoptStripePrice
            // is built around exactly this merging.
            $this->prices[$priceId]['metadata'] = [
                ...$this->prices[$priceId]['metadata'],
                ...$metadata,
            ];
        }

        $this->metadataUpdates[] = ['price' => $priceId, 'metadata' => $metadata];
    }

    /**
     * A Price that exists at Stripe and in no table of ours — the orphan a run
     * leaves behind when it dies between Stripe's create and our insert.
     *
     * Here rather than in a test file because it is the state the whole adoption
     * step exists for, and every test that needs it would otherwise reach into
     * $prices and write the shape by hand.
     */
    public function plantPrice(
        string $id,
        string $productId,
        int $amountCents,
        string $currency,
        string $interval,
        array $metadata = [],
        int $created = 1,
        int $intervalCount = 1,
        string $usageType = 'licensed',
        bool $transformQuantity = false,
        string $billingScheme = 'per_unit',
    ): void {
        $this->prices[$id] = [
            'product' => $productId,
            'amount' => $amountCents,
            'currency' => $currency,
            'interval' => $interval,
            // Defaulted to what we could have minted ourselves, so a test that
            // does not care says nothing — and a test about the money gate says
            // exactly which property it is about.
            'interval_count' => $intervalCount,
            'usage_type' => $usageType,
            'transform_quantity' => $transformQuantity,
            'billing_scheme' => $billingScheme,
            'metadata' => $metadata,
            'created' => $created,
        ];
    }

    public function updateSubscriptionPrice(
        string $subscriptionId,
        string $itemId,
        string $priceId,
        string $prorationBehaviour,
    ): void {
        $this->failIfAsked();

        $this->priceChanges[] = [
            'subscription' => $subscriptionId,
            'item' => $itemId,
            'price' => $priceId,
            'proration' => $prorationBehaviour,
        ];
    }

    public function subscriptionItemId(string $subscriptionId): ?string
    {
        $this->failIfAsked();

        return $this->items[$subscriptionId] ?? null;
    }

    public function addSubscriptionItem(
        string $subscriptionId,
        string $priceId,
        int $quantity,
        string $prorationBehaviour,
        ?string $idempotencyKey = null,
    ): string {
        $this->failIfAsked();

        $id = 'si_'.substr(sha1($subscriptionId.$priceId.count($this->subscriptionItems)), 0, 12);

        $this->subscriptionItems[$id] = [
            'subscription' => $subscriptionId,
            'price' => $priceId,
            'quantity' => $quantity,
        ];

        $this->itemCalls[] = [
            'call' => 'add',
            'subscription' => $subscriptionId,
            'item' => $id,
            'price' => $priceId,
            'quantity' => $quantity,
            'proration' => $prorationBehaviour,
        ];

        return $id;
    }

    public function updateSubscriptionItemQuantity(
        string $itemId,
        int $quantity,
        string $prorationBehaviour,
    ): void {
        $this->failIfAsked();

        if (isset($this->subscriptionItems[$itemId])) {
            $this->subscriptionItems[$itemId]['quantity'] = $quantity;
        }

        $this->itemCalls[] = [
            'call' => 'quantity',
            'item' => $itemId,
            'quantity' => $quantity,
            'proration' => $prorationBehaviour,
        ];
    }

    public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void
    {
        $this->failIfAsked();

        unset($this->subscriptionItems[$itemId]);

        $this->itemCalls[] = [
            'call' => 'remove',
            'item' => $itemId,
            'proration' => $prorationBehaviour,
        ];
    }

    public function cancelSubscription(
        string $subscriptionId,
        string $when,
        ?string $idempotencyKey = null,
    ): void {
        $this->failIfAsked();

        // Refused here as well as in HttpStripeClient, so a caller that invents a
        // third timing fails in the tests rather than in production.
        if (! in_array($when, [self::CANCEL_AT_PERIOD_END, self::CANCEL_IMMEDIATELY], true)) {
            throw new RuntimeException("Unknown cancellation timing: {$when}");
        }

        // No fingerprint: for a subscription cancellation, a changed parameter
        // under a used key is exactly what must fail loudly. See IdempotencyKey.
        $parameters = ['subscription' => $subscriptionId, 'when' => $when];
        $replayed = $this->replay($idempotencyKey, $parameters);

        if ($replayed !== null) {
            return;
        }

        $this->cancellations[] = [
            'subscription' => $subscriptionId,
            'when' => $when,
            'key' => $idempotencyKey,
        ];

        $this->rememberKey($idempotencyKey, $subscriptionId, $parameters);
    }

    public function refund(
        string $paymentReference,
        ?int $amountCents = null,
        ?string $idempotencyKey = null,
    ): string {
        $this->failIfAsked();

        // No fingerprint: for a refund, a changed parameter under a used key is
        // exactly what must fail loudly. See IdempotencyKey.
        $parameters = ['payment' => $paymentReference, 'amount' => (string) $amountCents];
        $replayed = $this->replay($idempotencyKey, $parameters);

        if ($replayed !== null) {
            return $replayed;
        }

        $this->refunds[] = [
            'payment' => $paymentReference,
            'amount' => $amountCents,
            'key' => $idempotencyKey,
        ];

        $id = 're_'.substr(sha1($paymentReference.count($this->refunds)), 0, 12);

        $this->rememberKey($idempotencyKey, $id, $parameters);

        return $id;
    }

    public function invoicePaymentReference(string $invoiceId): ?string
    {
        $this->failIfAsked();

        return $this->invoicePayments[$invoiceId] ?? null;
    }

    public function invoiceLines(string $invoiceId): array
    {
        $this->failIfAsked();

        return $this->invoiceLines[$invoiceId] ?? [];
    }

    /** The module items this Stripe holds for one subscription. */
    public function itemsOn(string $subscriptionId): array
    {
        return array_filter(
            $this->subscriptionItems,
            fn (array $item) => $item['subscription'] === $subscriptionId,
        );
    }

    private function failIfAsked(): void
    {
        if ($this->failWith !== null) {
            throw new RuntimeException($this->failWith);
        }
    }

    /**
     * The id Stripe already answered for this key, or null when it is new.
     *
     * Throws on a key that comes back with different parameters, which is what
     * Stripe does — with this wording — and what makes a test able to see the
     * failure at all.
     */
    private function replay(?string $key, array $parameters): ?string
    {
        if ($key === null || ! isset($this->keys[$key])) {
            return null;
        }

        if ($this->keys[$key]['fingerprint'] !== IdempotencyKey::fingerprint($parameters)) {
            throw new RuntimeException(
                'Keys for idempotent requests can only be used with the same parameters they were first used with.',
            );
        }

        return $this->keys[$key]['id'];
    }

    private function rememberKey(?string $key, string $id, array $parameters): void
    {
        if ($key !== null) {
            $this->keys[$key] = ['id' => $id, 'fingerprint' => IdempotencyKey::fingerprint($parameters)];
        }
    }

    /** @var array<string, string> customerId => paymentMethodId */
    public array $defaultPaymentMethods = [];

    public function createSetupIntent(string $customerId): array
    {
        return ['id' => 'seti_fake', 'client_secret' => 'seti_fake_secret_'.$customerId];
    }

    public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void
    {
        $this->defaultPaymentMethods[$customerId] = $paymentMethodId;
    }

    public function defaultPaymentMethod(string $customerId): ?array
    {
        if (! isset($this->defaultPaymentMethods[$customerId])) {
            return null;
        }

        return [
            'id' => $this->defaultPaymentMethods[$customerId],
            'brand' => 'visa',
            'last4' => '4242',
            'exp_month' => 12,
            'exp_year' => 2030,
        ];
    }

    /** @var array<int, array<string, mixed>> Was openInvoices() zurückgibt. */
    public array $openInvoiceRows = [];

    public bool $payDeclines = false;

    /** @var array<int, string> */
    public array $paidInvoices = [];

    public function openInvoices(string $customerId): array
    {
        return $this->openInvoiceRows;
    }

    public function payInvoice(string $invoiceId): array
    {
        if ($this->payDeclines) {
            return ['paid' => false, 'status' => 'open', 'failure' => 'Your card was declined.'];
        }

        $this->paidInvoices[] = $invoiceId;
        $this->openInvoiceRows = array_values(array_filter(
            $this->openInvoiceRows,
            fn (array $invoice) => $invoice['id'] !== $invoiceId,
        ));

        return ['paid' => true, 'status' => 'paid', 'failure' => null];
    }

    /** @var array<int, array{customer: string, amount: int, currency: string, description: string, level: int|null}> */
    public array $feeInvoices = [];

    public function createFeeInvoice(
        string $customerId,
        int $amountCents,
        string $currency,
        string $description,
        ?string $idempotencyKey = null,
    ): string {
        $this->feeInvoices[] = [
            'customer' => $customerId,
            'amount' => $amountCents,
            'currency' => $currency,
            'description' => $description,
            // Aus der Beschreibung gezogen, damit ein Test die Stufe prüfen
            // kann, ohne dass der Fake sie gesondert übergeben bekäme.
            'level' => preg_match('/(\d+)/', $description, $m) ? (int) $m[1] : null,
            'idempotency_key' => $idempotencyKey,
        ];

        return 'in_fee_'.count($this->feeInvoices);
    }
}
